From 5b386c6d9ad1b6bf4d53855903f9a2dda18272a2 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 04:39:39 +0900 Subject: [PATCH 01/45] test: the target rows' equivalence snapshot, written by the code before the move to data Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- tests/fixtures/target-rows.snapshot | 784 +++++++++++++++++++++++ tests/unit/test_target_rows_snapshot.cpp | 161 +++++ 2 files changed, 945 insertions(+) create mode 100644 tests/fixtures/target-rows.snapshot create mode 100644 tests/unit/test_target_rows_snapshot.cpp diff --git a/tests/fixtures/target-rows.snapshot b/tests/fixtures/target-rows.snapshot new file mode 100644 index 000000000..8be8378ba --- /dev/null +++ b/tests/fixtures/target-rows.snapshot @@ -0,0 +1,784 @@ +row x86_64-linux-gnu + table tier=verified note= pin= sysroot= static=0 platform= + str x86_64-linux-gnu + llvm x86_64-unknown-linux-gnu + llvm@15.0 x86_64-unknown-linux-gnu + format ELF + family unix + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 0000 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch x64 + nasm elf64 + naming exe= lib=lib static=.a shared=.so import=0 sharedPrefix=- + application exe + android-abi x86_64 + platform-name linux + descriptor-keys linux + sysroot +row x86_64-linux-musl + table tier=verified note= pin=gcc@16.1.0 sysroot= static=1 platform= + str x86_64-linux-musl + llvm x86_64-unknown-linux-musl + llvm@15.0 x86_64-unknown-linux-musl + format ELF + family unix + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 0000 + musl/msvc/windows-gnu/x86_32 1000 + msvc-arch x64 + nasm elf64 + naming exe= lib=lib static=.a shared=.so import=0 sharedPrefix=- + application exe + android-abi x86_64 + platform-name linux + descriptor-keys linux + sysroot +row aarch64-linux-musl + table tier=verified note= pin=gcc@16.1.0 sysroot= static=1 platform= + str aarch64-linux-musl + llvm aarch64-unknown-linux-musl + llvm@15.0 aarch64-unknown-linux-musl + format ELF + family unix + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 0000 + musl/msvc/windows-gnu/x86_32 1000 + msvc-arch arm64 + nasm - + naming exe= lib=lib static=.a shared=.so import=0 sharedPrefix=- + application exe + android-abi arm64-v8a + platform-name linux + descriptor-keys linux + sysroot +row x86_64-windows-gnu + table tier=verified note=PE pin=gcc@16.1.0 sysroot= static=1 platform= + str x86_64-windows-gnu + llvm x86_64-w64-windows-gnu + llvm@15.0 x86_64-w64-windows-gnu + format PE + family windows + pe/macho/wasm 100 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 0000 + musl/msvc/windows-gnu/x86_32 0010 + msvc-arch x64 + nasm win64 + naming exe=.exe lib=lib static=.a shared=.dll import=1 sharedPrefix=- + application exe + android-abi x86_64 + platform-name windows + descriptor-keys windows + sysroot +row x86_64-windows-musl + table tier=preview note=PE pin=llvm@23.1.3 sysroot= static=1 platform= + str x86_64-windows-musl + llvm x86_64-w64-windows-gnu + llvm@15.0 x86_64-w64-windows-gnu + format PE + family windows + pe/macho/wasm 100 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 0001 + musl/msvc/windows-gnu/x86_32 1000 + msvc-arch x64 + nasm win64 + naming exe=.exe lib=lib static=.a shared=.dll import=1 sharedPrefix=- + application exe + android-abi x86_64 + platform-name windows + descriptor-keys windows + sysroot +row x86_64-windows-msvc + table tier=verified note=PE pin= sysroot= static=0 platform= + str x86_64-windows-msvc + llvm x86_64-pc-windows-msvc + llvm@15.0 x86_64-pc-windows-msvc + format PE + family windows + pe/macho/wasm 100 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 0000 + musl/msvc/windows-gnu/x86_32 0100 + msvc-arch x64 + nasm win64 + naming exe=.exe lib= static=.lib shared=.dll import=1 sharedPrefix=- + application exe + android-abi x86_64 + platform-name windows + descriptor-keys windows + sysroot +row aarch64-macos + table tier=verified note= pin= sysroot= static=0 platform= + str aarch64-macos + llvm arm64-apple-macos14.0 + llvm@15.0 arm64-apple-macos15.0 + format Mach-O + family unix + pe/macho/wasm 010 + apple/ios/sim/android 1000 + freestanding/openkal/own-sysroot/capability 0000 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch arm64 + nasm - + naming exe= lib=lib static=.a shared=.dylib import=0 sharedPrefix=- + application exe + android-abi arm64-v8a + platform-name macos + descriptor-keys macosx + sysroot +row riscv64-linux-musl + table tier=planned note= pin= sysroot= static=1 platform= + str riscv64-linux-musl + llvm riscv64-unknown-linux-musl + llvm@15.0 riscv64-unknown-linux-musl + format ELF + family unix + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 0000 + musl/msvc/windows-gnu/x86_32 1000 + msvc-arch + nasm - + naming exe= lib=lib static=.a shared=.so import=0 sharedPrefix=- + application exe + android-abi riscv64 + platform-name linux + descriptor-keys linux + sysroot +row aarch64-linux-gnu + table tier=preview note= pin=llvm@23.1.3 sysroot= static=0 platform= + str aarch64-linux-gnu + llvm aarch64-unknown-linux-gnu + llvm@15.0 aarch64-unknown-linux-gnu + format ELF + family unix + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 0000 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch arm64 + nasm - + naming exe= lib=lib static=.a shared=.so import=0 sharedPrefix=- + application exe + android-abi arm64-v8a + platform-name linux + descriptor-keys linux + sysroot +row x86_64-macos + table tier=planned note= pin= sysroot= static=0 platform= + str x86_64-macos + llvm x86_64-apple-macos14.0 + llvm@15.0 x86_64-apple-macos15.0 + format Mach-O + family unix + pe/macho/wasm 010 + apple/ios/sim/android 1000 + freestanding/openkal/own-sysroot/capability 0000 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch x64 + nasm macho64 + naming exe= lib=lib static=.a shared=.dylib import=0 sharedPrefix=- + application exe + android-abi x86_64 + platform-name macos + descriptor-keys macosx + sysroot +row riscv64-none-elf + table tier=verified note=bare pin=llvm@23.1.3 sysroot=xim:picolibc-riscv@1.8.12 static=1 platform= + str riscv64-none-elf + llvm riscv64-none-elf + llvm@15.0 riscv64-none-elf + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 1001 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi riscv64 + platform-name none + descriptor-keys none + sysroot xim:picolibc-riscv@1.8.12 + fs.spec march=rv64gc mabi=lp64d mcmodel=medany libdir=rv64gc/lp64d lld= + fs.compile -march=rv64gc -mabi=lp64d -mcmodel=medany -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti + fs.compile+rt -march=rv64gc -mabi=lp64d -mcmodel=medany -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ + fs.link --target=riscv64-none-elf -march=rv64gc -mabi=lp64d -mcmodel=medany -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld +row riscv32-none-elf + table tier=verified note=bare pin=llvm@23.1.3 sysroot=xim:picolibc-riscv@1.8.12 static=1 platform= + str riscv32-none-elf + llvm riscv32-none-elf + llvm@15.0 riscv32-none-elf + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 1001 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi riscv32 + platform-name none + descriptor-keys none + sysroot xim:picolibc-riscv@1.8.12 + fs.spec march=rv32imac mabi=ilp32 mcmodel=medany libdir=rv32imac/ilp32 lld= + fs.compile -march=rv32imac -mabi=ilp32 -mcmodel=medany -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti + fs.compile+rt -march=rv32imac -mabi=ilp32 -mcmodel=medany -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ + fs.link --target=riscv32-none-elf -march=rv32imac -mabi=ilp32 -mcmodel=medany -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld +row aarch64-none-elf + table tier=preview note=bare pin=llvm@23.1.3 sysroot= static=1 platform= + str aarch64-none-elf + llvm aarch64-none-elf + llvm@15.0 aarch64-none-elf + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 1001 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch arm64 + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi arm64-v8a + platform-name none + descriptor-keys none + sysroot + fs.spec march=armv8-a mabi=aapcs mcmodel=small libdir=armv8-a/aapcs lld= + fs.compile -march=armv8-a -mabi=aapcs -mcmodel=small -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti + fs.compile+rt -march=armv8-a -mabi=aapcs -mcmodel=small -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ + fs.link --target=aarch64-none-elf -march=armv8-a -mabi=aapcs -mcmodel=small -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld +row x86_64-none-elf + table tier=preview note=bare pin=llvm@23.1.3 sysroot= static=1 platform= + str x86_64-none-elf + llvm x86_64-none-elf + llvm@15.0 x86_64-none-elf + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 1001 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch x64 + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi x86_64 + platform-name none + descriptor-keys none + sysroot + fs.spec march=x86-64 mabi=sysv mcmodel=small libdir=x86-64/sysv lld=elf_x86_64 + fs.compile -march=x86-64 -mabi=sysv -mcmodel=small -mno-red-zone -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti + fs.compile+rt -march=x86-64 -mabi=sysv -mcmodel=small -mno-red-zone -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ + fs.link --target=x86_64-none-elf -march=x86-64 -mabi=sysv -mcmodel=small -mno-red-zone -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld + fs.link-direct -m elf_x86_64 -Bstatic --no-dynamic-linker --gc-sections -L/sysroot/lib -T /script.ld +row thumbv6m-none-eabi + table tier=verified note=bare pin=llvm@23.1.3 sysroot= static=1 platform= + str thumbv6m-none-eabi + llvm thumbv6m-none-eabi + llvm@15.0 thumbv6m-none-eabi + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 1001 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi thumbv6m + platform-name none + descriptor-keys none + sysroot + fs.spec march=armv6-m mabi=aapcs mcmodel= libdir=thumbv6m-none-eabi lld= + fs.compile -march=armv6-m -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti + fs.compile+rt -march=armv6-m -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ + fs.link --target=thumbv6m-none-eabi -march=armv6-m -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld +row thumbv7m-none-eabi + table tier=verified note=bare pin=llvm@23.1.3 sysroot= static=1 platform= + str thumbv7m-none-eabi + llvm thumbv7m-none-eabi + llvm@15.0 thumbv7m-none-eabi + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 1001 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi thumbv7m + platform-name none + descriptor-keys none + sysroot + fs.spec march=armv7-m mabi=aapcs mcmodel= libdir=thumbv7m-none-eabi lld= + fs.compile -march=armv7-m -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti + fs.compile+rt -march=armv7-m -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ + fs.link --target=thumbv7m-none-eabi -march=armv7-m -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld +row thumbv7em-none-eabi + table tier=preview note=bare pin=llvm@23.1.3 sysroot= static=1 platform= + str thumbv7em-none-eabi + llvm thumbv7em-none-eabi + llvm@15.0 thumbv7em-none-eabi + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 1001 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi thumbv7em + platform-name none + descriptor-keys none + sysroot + fs.spec march=armv7e-m mabi=aapcs mcmodel= libdir=thumbv7em-none-eabi lld= + fs.compile -march=armv7e-m -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti + fs.compile+rt -march=armv7e-m -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ + fs.link --target=thumbv7em-none-eabi -march=armv7e-m -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld +row thumbv7em-none-eabihf + table tier=verified note=bare pin=llvm@23.1.3 sysroot= static=1 platform= + str thumbv7em-none-eabihf + llvm thumbv7em-none-eabihf + llvm@15.0 thumbv7em-none-eabihf + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 1001 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi thumbv7em + platform-name none + descriptor-keys none + sysroot + fs.spec march=armv7e-m mabi=aapcs mcmodel= libdir=thumbv7em-none-eabihf lld= + fs.compile -march=armv7e-m -mabi=aapcs -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti + fs.compile+rt -march=armv7e-m -mabi=aapcs -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ + fs.link --target=thumbv7em-none-eabihf -march=armv7e-m -mabi=aapcs -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld +row thumbv8m.base-none-eabi + table tier=preview note=bare pin=llvm@23.1.3 sysroot= static=1 platform= + str thumbv8m.base-none-eabi + llvm thumbv8m.base-none-eabi + llvm@15.0 thumbv8m.base-none-eabi + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 1001 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi thumbv8m.base + platform-name none + descriptor-keys none + sysroot + fs.spec march=armv8-m.base mabi=aapcs mcmodel= libdir=thumbv8m.base-none-eabi lld= + fs.compile -march=armv8-m.base -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti + fs.compile+rt -march=armv8-m.base -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ + fs.link --target=thumbv8m.base-none-eabi -march=armv8-m.base -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld +row thumbv8m.main-none-eabi + table tier=verified note=bare pin=llvm@23.1.3 sysroot= static=1 platform= + str thumbv8m.main-none-eabi + llvm thumbv8m.main-none-eabi + llvm@15.0 thumbv8m.main-none-eabi + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 1001 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi thumbv8m.main + platform-name none + descriptor-keys none + sysroot + fs.spec march=armv8-m.main mabi=aapcs mcmodel= libdir=thumbv8m.main-none-eabi lld= + fs.compile -march=armv8-m.main -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti + fs.compile+rt -march=armv8-m.main -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ + fs.link --target=thumbv8m.main-none-eabi -march=armv8-m.main -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld +row thumbv8m.main-none-eabihf + table tier=preview note=bare pin=llvm@23.1.3 sysroot= static=1 platform= + str thumbv8m.main-none-eabihf + llvm thumbv8m.main-none-eabihf + llvm@15.0 thumbv8m.main-none-eabihf + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 1001 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi thumbv8m.main + platform-name none + descriptor-keys none + sysroot + fs.spec march=armv8-m.main mabi=aapcs mcmodel= libdir=thumbv8m.main-none-eabihf lld= + fs.compile -march=armv8-m.main -mabi=aapcs -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti + fs.compile+rt -march=armv8-m.main -mabi=aapcs -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ + fs.link --target=thumbv8m.main-none-eabihf -march=armv8-m.main -mabi=aapcs -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld +row armv7a-none-eabi + table tier=verified note=bare pin=llvm@23.1.3 sysroot= static=1 platform= + str armv7a-none-eabi + llvm armv7a-none-eabi + llvm@15.0 armv7a-none-eabi + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 1001 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi armv7a + platform-name none + descriptor-keys none + sysroot + fs.spec march=armv7-a mabi=aapcs mcmodel= libdir=armv7a-none-eabi lld= + fs.compile -march=armv7-a -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti + fs.compile+rt -march=armv7-a -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ + fs.link --target=armv7a-none-eabi -march=armv7-a -mabi=aapcs -mfpu=none -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld +row armv7a-none-eabihf + table tier=verified note=bare pin=llvm@23.1.3 sysroot= static=1 platform= + str armv7a-none-eabihf + llvm armv7a-none-eabihf + llvm@15.0 armv7a-none-eabihf + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 1001 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi armv7a + platform-name none + descriptor-keys none + sysroot + fs.spec march=armv7-a mabi=aapcs mcmodel= libdir=armv7a-none-eabihf lld= + fs.compile -march=armv7-a -mabi=aapcs -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti + fs.compile+rt -march=armv7-a -mabi=aapcs -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ + fs.link --target=armv7a-none-eabihf -march=armv7-a -mabi=aapcs -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld +row aarch64-linux-android + table tier=verified note= pin=android-ndk@30.0.16248370 sysroot= static=0 platform= + str aarch64-linux-android + llvm aarch64-unknown-linux-android + llvm@15.0 aarch64-unknown-linux-android15.0 + format ELF + family unix + pe/macho/wasm 000 + apple/ios/sim/android 0001 + freestanding/openkal/own-sysroot/capability 0011 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch arm64 + nasm - + naming exe= lib=lib static=.a shared=.so import=0 sharedPrefix=- + application shared + android-abi arm64-v8a + platform-name android + descriptor-keys linux + sysroot +row x86_64-linux-android + table tier=verified note= pin=android-ndk@30.0.16248370 sysroot= static=0 platform= + str x86_64-linux-android + llvm x86_64-unknown-linux-android + llvm@15.0 x86_64-unknown-linux-android15.0 + format ELF + family unix + pe/macho/wasm 000 + apple/ios/sim/android 0001 + freestanding/openkal/own-sysroot/capability 0011 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch x64 + nasm elf64 + naming exe= lib=lib static=.a shared=.so import=0 sharedPrefix=- + application shared + android-abi x86_64 + platform-name android + descriptor-keys linux + sysroot +row aarch64-ios + table tier=preview note= pin=llvm@23.1.3 sysroot= static=0 platform= + str aarch64-ios + llvm arm64-apple-ios + llvm@15.0 arm64-apple-ios15.0 + format Mach-O + family unix + pe/macho/wasm 010 + apple/ios/sim/android 1100 + freestanding/openkal/own-sysroot/capability 0000 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch arm64 + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi arm64-v8a + platform-name ios + descriptor-keys ios macosx + sysroot +row aarch64-ios-sim + table tier=verified note= pin=llvm@23.1.3 sysroot= static=0 platform= + str aarch64-ios-sim + llvm arm64-apple-ios-simulator + llvm@15.0 arm64-apple-ios15.0-simulator + format Mach-O + family unix + pe/macho/wasm 010 + apple/ios/sim/android 1110 + freestanding/openkal/own-sysroot/capability 0000 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch arm64 + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi arm64-v8a + platform-name ios + descriptor-keys ios macosx + sysroot +row x86_64-ios-sim + table tier=verified note= pin=llvm@23.1.3 sysroot= static=0 platform= + str x86_64-ios-sim + llvm x86_64-apple-ios-simulator + llvm@15.0 x86_64-apple-ios15.0-simulator + format Mach-O + family unix + pe/macho/wasm 010 + apple/ios/sim/android 1110 + freestanding/openkal/own-sysroot/capability 0000 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch x64 + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi x86_64 + platform-name ios + descriptor-keys ios macosx + sysroot +row wasm32-emscripten + table tier=verified note=wasm pin=emsdk@6.0.9 sysroot= static=1 platform= + str wasm32-emscripten + llvm wasm32-unknown-emscripten + llvm@15.0 wasm32-unknown-emscripten + format wasm + family unix + pe/macho/wasm 001 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 0011 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch + nasm - + naming exe=.js lib=lib static=.a shared= import=1 sharedPrefix=- + application exe + android-abi wasm32 + platform-name emscripten + descriptor-keys emscripten linux + sysroot +row x86_64-openkal + table tier=verified note=ok pin=llvm@23.1.3 sysroot= static=1 platform=mcpplibs.openkal-platform@0.24.4 + str x86_64-openkal + llvm x86_64-unknown-none-elf + llvm@15.0 x86_64-unknown-none-elf + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 0101 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch x64 + nasm - + naming exe=.oke lib=lib static=.a shared=.okso import=0 sharedPrefix=- + application exe + android-abi x86_64 + platform-name openkal + descriptor-keys openkal linux + sysroot +row aarch64-openkal + table tier=verified note=ok pin=llvm@23.1.3 sysroot= static=1 platform=mcpplibs.openkal-platform@0.24.4 + str aarch64-openkal + llvm aarch64-unknown-none-elf + llvm@15.0 aarch64-unknown-none-elf + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 0101 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch arm64 + nasm - + naming exe=.oke lib=lib static=.a shared=.okso import=0 sharedPrefix=- + application exe + android-abi arm64-v8a + platform-name openkal + descriptor-keys openkal linux + sysroot +request x86_64-linux + parsed x86_64-linux-gnu envExplicit=0 + resolved x86_64-linux-gnu completed=0 ambiguous=0 + siblings x86_64-linux-gnu x86_64-linux-musl + supported x86_64-linux-gnu x86_64-linux-musl + known 1 +request aarch64-linux + parsed aarch64-linux-gnu envExplicit=0 + resolved aarch64-linux-gnu completed=0 ambiguous=0 + siblings aarch64-linux-musl aarch64-linux-gnu + supported aarch64-linux-musl aarch64-linux-gnu + known 1 +request riscv64-linux + parsed riscv64-linux-gnu envExplicit=0 + resolved riscv64-linux-gnu completed=0 ambiguous=0 + siblings riscv64-linux-musl + supported + known 0 +request x86_64-windows + parsed x86_64-windows-gnu envExplicit=0 + resolved x86_64-windows-gnu completed=0 ambiguous=0 + siblings x86_64-windows-gnu x86_64-windows-musl x86_64-windows-msvc + supported x86_64-windows-gnu x86_64-windows-musl x86_64-windows-msvc + known 1 +request i686-windows + parsed i686-windows-gnu envExplicit=0 + resolved i686-windows-gnu completed=0 ambiguous=0 + siblings + supported + known 0 +request aarch64-windows + parsed aarch64-windows-gnu envExplicit=0 + resolved aarch64-windows-gnu completed=0 ambiguous=0 + siblings + supported + known 0 +request x86_64-macos + parsed x86_64-macos envExplicit=0 + resolved x86_64-macos completed=0 ambiguous=0 + siblings x86_64-macos + supported + known 1 +request aarch64-macos + parsed aarch64-macos envExplicit=0 + resolved aarch64-macos completed=0 ambiguous=0 + siblings aarch64-macos + supported aarch64-macos + known 1 +request aarch64-ios + parsed aarch64-ios envExplicit=0 + resolved aarch64-ios completed=0 ambiguous=0 + siblings aarch64-ios aarch64-ios-sim + supported aarch64-ios aarch64-ios-sim + known 1 +request x86_64-openkal + parsed x86_64-openkal envExplicit=0 + resolved x86_64-openkal completed=0 ambiguous=0 + siblings x86_64-openkal + supported x86_64-openkal + known 1 +request x86_64-w64-mingw32 + parsed x86_64-windows-gnu envExplicit=1 + resolved x86_64-windows-gnu completed=0 ambiguous=0 + siblings + supported + known 1 +request x86_64-unknown-linux-gnu + parsed x86_64-linux-gnu envExplicit=1 + resolved x86_64-linux-gnu completed=0 ambiguous=0 + siblings + supported + known 1 +request x86_64-pc-windows-msvc + parsed x86_64-windows-msvc envExplicit=1 + resolved x86_64-windows-msvc completed=0 ambiguous=0 + siblings + supported + known 1 +request arm64-apple-darwin24.1.0 + parsed aarch64-macos envExplicit=0 + resolved aarch64-macos completed=0 ambiguous=0 + siblings aarch64-macos + supported aarch64-macos + known 1 +request arm64-apple-macosx15.0 + parsed aarch64-macos envExplicit=0 + resolved aarch64-macos completed=0 ambiguous=0 + siblings aarch64-macos + supported aarch64-macos + known 1 +request aarch64-unknown-linux-musl + parsed aarch64-linux-musl envExplicit=1 + resolved aarch64-linux-musl completed=0 ambiguous=0 + siblings + supported + known 1 +request aarch64-linux-android21 + parsed aarch64-linux-android envExplicit=1 + resolved aarch64-linux-android completed=0 ambiguous=0 + siblings + supported + known 1 +request wasm32-unknown-emscripten + parsed wasm32-emscripten envExplicit=0 + resolved wasm32-emscripten completed=0 ambiguous=0 + siblings wasm32-emscripten + supported wasm32-emscripten + known 1 +request x86-windows-msvc + parsed i686-windows-msvc envExplicit=1 + resolved i686-windows-msvc completed=0 ambiguous=0 + siblings + supported + known 0 +request i686-none-elf + parsed i686-none-elf envExplicit=1 + resolved i686-none-elf completed=0 ambiguous=0 + siblings + supported + known 0 +request x86_64-freebsd + unparseable +request x86_64-linux-gnux32 + unparseable +did-you-mean x86_64-linux-mus x86_64-linux-musl +did-you-mean x86-64-linux-gnu x86_64-linux-gnu +did-you-mean aarch64-macso aarch64-macos +did-you-mean riscv64-none-eld riscv64-none-elf +did-you-mean thumbv7m-none - +set linux -> x86_64-linux-gnu aarch64-linux-gnu | +set linux-musl -> x86_64-linux-musl aarch64-linux-musl | +set windows -> x86_64-windows-gnu | +set windows-msvc -> x86_64-windows-msvc | +set macos -> aarch64-macos | +set openkal -> x86_64-openkal aarch64-openkal | mcpplibs.openkal-platform@0.24.4 +set none-elf -> riscv64-none-elf riscv32-none-elf aarch64-none-elf x86_64-none-elf | +set none-eabihf -> thumbv7em-none-eabihf thumbv8m.main-none-eabihf armv7a-none-eabihf | +set android -> aarch64-linux-android x86_64-linux-android | +set ios -> aarch64-ios | +set ios-sim -> aarch64-ios-sim x86_64-ios-sim | +set emscripten -> wasm32-emscripten | +set none refused diff --git a/tests/unit/test_target_rows_snapshot.cpp b/tests/unit/test_target_rows_snapshot.cpp new file mode 100644 index 000000000..573313b5c --- /dev/null +++ b/tests/unit/test_target_rows_snapshot.cpp @@ -0,0 +1,161 @@ +#include + +import std; +import mcpp.toolchain.triple; +import mcpp.toolchain.platform; +import mcpp.freestanding.target; +import mcpp.freestanding.linkline; + +// THE EQUIVALENCE GATE OF THE TARGET MODEL (design 2026-11-09 §9, §14). +// +// The target rows moved from code to data. Every fact a row used to answer in +// code is rendered here, one line per fact, and compared with +// tests/fixtures/target-rows.snapshot, which was written by the code before the +// move. A difference is a change of behaviour of a built-in target; it is either +// a defect of the data or a deliberate change, and a deliberate change rewrites +// the snapshot (MCPP_SNAPSHOT_UPDATE=1) in the same commit that explains it. + +namespace tr = mcpp::toolchain::triple; +namespace fs = mcpp::freestanding; + +namespace { + +std::string join(const std::vector& v) { + std::string out; + for (auto const& s : v) { if (!out.empty()) out += ' '; out += s; } + return out; +} + +std::string yes(bool b) { return b ? "1" : "0"; } + +std::string facts_of(const tr::Triple& t) { + std::string o; + auto line = [&](std::string_view k, std::string v) { o += std::format(" {} {}\n", k, v); }; + line("str", t.str()); + line("llvm", t.llvm_triple()); + line("llvm@15.0", t.llvm_triple("15.0")); + line("format", std::string(tr::to_string(t.object_format()))); + line("family", t.family()); + line("pe/macho/wasm", yes(t.is_pe()) + yes(t.is_mach_o()) + yes(t.is_wasm())); + line("apple/ios/sim/android", yes(t.is_apple()) + yes(t.is_ios()) + yes(t.is_ios_simulator()) + + yes(t.is_android())); + line("freestanding/openkal/own-sysroot/capability", + yes(t.is_freestanding()) + yes(t.is_openkal()) + yes(t.has_own_sysroot()) + + yes(t.pin_is_capability())); + line("musl/msvc/windows-gnu/x86_32", yes(t.is_musl()) + yes(t.is_msvc_env()) + + yes(t.is_windows_gnu()) + yes(t.is_x86_32())); + line("msvc-arch", std::string(t.msvc_arch())); + line("nasm", t.nasm_format().value_or("-")); + const tr::ArtifactNaming host{".host", "hostlib", ".hosta", ".hostso", false}; + auto n = tr::artifact_naming(t, host); + line("naming", std::format("exe={} lib={} static={} shared={} import={} sharedPrefix={}", + n.exeSuffix, n.libPrefix, n.staticLibExt, n.sharedLibExt, + yes(n.sharedNeedsImportLib), + n.sharedLibPrefixSet ? std::string(n.sharedLibPrefix) : "-")); + line("application", tr::application_form(t) == tr::ApplicationForm::Executable ? "exe" : "shared"); + line("android-abi", tr::android_abi(t)); + line("platform-name", tr::platform_name(t)); + line("descriptor-keys", join(mcpp::toolchain::platform::descriptor_keys_for_os(t.os))); + line("sysroot", tr::effective_sysroot(t, nullptr)); + if (auto s = fs::resolve(t)) { + line("fs.spec", std::format("march={} mabi={} mcmodel={} libdir={} lld={}", s->march, s->mabi, + s->mcmodel, s->libdir, s->lldEmulation)); + line("fs.compile", join(fs::compile_flags(*s, false))); + line("fs.compile+rt", join(fs::compile_flags(*s, true))); + auto esc = [](const std::filesystem::path& p) { return p.generic_string(); }; + fs::LinkInputs in{ "/lld", "/script.ld", "/sysroot/lib", {}, {} }; + line("fs.link", fs::link_flags(*s, in, esc)); + if (!s->lldEmulation.empty()) line("fs.link-direct", fs::link_flags_direct(*s, in, esc)); + } + return o; +} + +std::string render() { + std::string o; + for (auto const& k : tr::known_targets()) { + o += std::format("row {}\n", k.canonical); + o += std::format(" table tier={} note={} pin={} sysroot={} static={} platform={}\n", k.tier, + k.note, k.pin, k.sysroot, yes(k.defaultStatic), k.platform); + if (auto t = tr::parse(k.canonical)) o += facts_of(*t); + else o += " unparseable\n"; + } + // The spellings a request may take, and what each resolves to before any + // provider is consulted. + static constexpr std::string_view kRequests[] = { + "x86_64-linux", "aarch64-linux", "riscv64-linux", "x86_64-windows", "i686-windows", + "aarch64-windows", "x86_64-macos", "aarch64-macos", "aarch64-ios", "x86_64-openkal", + "x86_64-w64-mingw32", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc", + "arm64-apple-darwin24.1.0", "arm64-apple-macosx15.0", "aarch64-unknown-linux-musl", + "aarch64-linux-android21", "wasm32-unknown-emscripten", "x86-windows-msvc", + "i686-none-elf", "x86_64-freebsd", "x86_64-linux-gnux32", + }; + for (auto r : kRequests) { + o += std::format("request {}\n", r); + auto t = tr::parse(r); + if (!t) { o += " unparseable\n"; continue; } + auto res = tr::resolve_request(*t); + std::vector sib(res.siblings.begin(), res.siblings.end()); + std::vector sup(res.supported.begin(), res.supported.end()); + o += std::format(" parsed {} envExplicit={}\n", t->str(), yes(t->envExplicit)); + o += std::format(" resolved {} completed={} ambiguous={}\n", res.triple.str(), + yes(res.completedFromVocabulary), yes(res.ambiguous)); + o += std::format(" siblings {}\n supported {}\n", join(sib), join(sup)); + o += std::format(" known {}\n", yes(tr::is_known_target(res.triple))); + } + static constexpr std::string_view kTypos[] = { + "x86_64-linux-mus", "x86-64-linux-gnu", "aarch64-macso", "riscv64-none-eld", "thumbv7m-none", + }; + for (auto r : kTypos) o += std::format("did-you-mean {} {}\n", r, tr::did_you_mean(r).value_or("-")); + namespace ts = mcpp::toolchain::target_set; + auto platformOf = [](const tr::Triple& t) -> std::expected { + if (auto* k = tr::find_known_target(t)) return std::string(k->platform); + return std::string{}; + }; + static constexpr std::string_view kSets[] = { + "linux", "linux-musl", "windows", "windows-msvc", "macos", "openkal", "none-elf", "none-eabihf", + "android", "ios", "ios-sim", "emscripten", "none", + }; + for (auto s : kSets) { + auto e = ts::expand_set(s, platformOf); + if (!e) { o += std::format("set {} refused\n", s); continue; } + o += std::format("set {} -> {} | {}\n", s, join(e->members), e->platform); + } + return o; +} + +std::optional snapshot_path() { + for (auto const* base : { "tests/fixtures/target-rows.snapshot", "../tests/fixtures/target-rows.snapshot", + "../../tests/fixtures/target-rows.snapshot" }) { + std::error_code ec; + if (std::filesystem::exists(std::filesystem::path(base).parent_path(), ec)) + return std::filesystem::path(base); + } + return std::nullopt; +} + +} // namespace + +TEST(TargetRows, EveryBuiltInRowAnswersAsTheSnapshotRecords) { + auto path = snapshot_path(); + ASSERT_TRUE(path) << "tests/fixtures not found from " << std::filesystem::current_path(); + const auto now = render(); + if (const char* u = std::getenv("MCPP_SNAPSHOT_UPDATE"); u && std::string_view(u) == "1") { + std::ofstream(*path, std::ios::binary) << now; + GTEST_SKIP() << "snapshot written to " << path->generic_string(); + } + std::ifstream in(*path, std::ios::binary); + ASSERT_TRUE(in) << "no snapshot at " << path->generic_string(); + const std::string was{std::istreambuf_iterator(in), {}}; + if (now == was) return; + // The first differing line, with its row, so the failure names the fact. + std::istringstream a(was), b(now); + std::string la, lb, row; + for (int n = 1;; ++n) { + const bool ga = bool(std::getline(a, la)), gb = bool(std::getline(b, lb)); + if (!ga && !gb) break; + if (gb && (lb.starts_with("row ") || lb.starts_with("request "))) row = lb; + if (la != lb || ga != gb) + FAIL() << "line " << n << " (" << row << ")\n snapshot: " << (ga ? la : "") + << "\n now: " << (gb ? lb : ""); + } +} From 6491b8c334b560f294d908b34ea166551f8d178f Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 04:53:53 +0900 Subject: [PATCH 02/45] target rows and providers are data: data/builtin.toml, compiled by build.mcpp into static tables The snapshot keeps every existing row's facts; it changes where the data adds: the musl rows of macOS, the i686-none-elf row (#911 G12), and the platform of x86_64-windows-musl, whose default provider is now openkal-platform. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- modules/toolchain-model/build.mcpp | 201 +++++++ modules/toolchain-model/data/builtin.toml | 467 +++++++++++++++++ modules/toolchain-model/mcpp.toml | 5 + .../toolchain-model/src/target_schema.cppm | 98 ++++ modules/toolchain-model/src/triple.cppm | 490 +++--------------- src/freestanding/target.cppm | 268 +--------- tests/fixtures/target-rows.snapshot | 86 ++- 7 files changed, 935 insertions(+), 680 deletions(-) create mode 100644 modules/toolchain-model/build.mcpp create mode 100644 modules/toolchain-model/data/builtin.toml create mode 100644 modules/toolchain-model/src/target_schema.cppm diff --git a/modules/toolchain-model/build.mcpp b/modules/toolchain-model/build.mcpp new file mode 100644 index 000000000..17d5e6a9b --- /dev/null +++ b/modules/toolchain-model/build.mcpp @@ -0,0 +1,201 @@ +// The built-in target rows and providers, compiled into the engine. +// +// data/builtin.toml is the source (the schema of `[[platform.define]]` and +// `[[platform.provider]]`, SPEC-016); this program writes the module +// mcpp.toolchain.builtin_targets, whose static tables the engine reads. A build +// therefore parses no file to know its targets. The output is written only when +// its content changes, so an unchanged table recompiles nothing. + +import std; +import mcpp; +import mcpp.libs.toml; + +namespace toml = mcpp::libs::toml; + +namespace { + +constexpr std::string_view kRowKeys[] = { + "triple", "tier", "note", "standard", "march", "mabi", "code-model", "multilib", "flags", + "link-emulation", +}; +constexpr std::string_view kProviderKeys[] = { + "name", "serves", "priority", "hosts", "probe", "toolchain", "pairs", "sysroot", "linkage", + "platform", +}; +constexpr std::string_view kTiers[] = { "verified", "preview", "planned" }; + +struct Failure { std::string text; }; + +std::string lit(std::string_view s) { + std::string o = "\""; + for (char c : s) { + if (c == '"' || c == '\\') o += '\\'; + o += c; + } + return o + "\""; +} + +std::string text(const toml::Table& t, std::string_view key, std::string_view where) { + auto it = t.find(key); + if (it == t.end()) return {}; + if (!it->second.is_string()) + throw Failure{std::format("{}: `{}` is not a string", where, key)}; + return it->second.as_string(); +} + +std::vector words(const toml::Table& t, std::string_view key, std::string_view where) { + std::vector out; + auto it = t.find(key); + if (it == t.end()) return out; + if (!it->second.is_array()) + throw Failure{std::format("{}: `{}` is not an array", where, key)}; + for (auto const& v : it->second.as_array()) { + if (!v.is_string()) throw Failure{std::format("{}: `{}` holds a non-string", where, key)}; + out.push_back(v.as_string()); + } + return out; +} + +void known_keys(const toml::Table& t, std::span keys, std::string_view where) { + for (auto const& [k, v] : t) + if (std::ranges::find(keys, k) == keys.end()) + throw Failure{std::format("{}: unknown key `{}`", where, k)}; +} + +// A named array of words in the generated module; the entry's field views it. +struct Arrays { + std::string decls; + int n = 0; + std::string add(const std::vector& v) { + if (v.empty()) return "{}"; + auto name = std::format("a{}", n++); + decls += std::format("inline constexpr std::string_view {}[] = {{", name); + for (std::size_t i = 0; i < v.size(); ++i) decls += (i ? ", " : "") + lit(v[i]); + decls += "};\n"; + return "d::" + name; + } +}; + +std::string row_entry(const toml::Table& t, Arrays& arrays, std::set& seen) { + known_keys(t, kRowKeys, "[[platform.define]]"); + const auto triple = text(t, "triple", "[[platform.define]]"); + const auto where = std::format("[[platform.define]] {}", triple); + if (triple.empty()) throw Failure{"[[platform.define]]: an entry without `triple`"}; + if (!seen.insert(triple).second) throw Failure{std::format("{}: defined twice", where)}; + const auto tier = text(t, "tier", where); + if (std::ranges::find(kTiers, tier) == std::end(kTiers)) + throw Failure{std::format("{}: tier `{}` is not verified, preview or planned", where, tier)}; + bool standard = true; + if (auto it = t.find("standard"); it != t.end()) { + if (!it->second.is_bool()) throw Failure{std::format("{}: `standard` is not a boolean", where)}; + standard = it->second.as_bool(); + } + return std::format( + " {{ .triple = {}, .tier = {}, .note = {}, .standard = {}, .march = {}, .mabi = {}, " + ".codeModel = {}, .multilib = {}, .flags = {}, .linkEmulation = {} }},\n", + lit(triple), lit(tier), lit(text(t, "note", where)), standard ? "true" : "false", + lit(text(t, "march", where)), lit(text(t, "mabi", where)), + lit(text(t, "code-model", where)), lit(text(t, "multilib", where)), + arrays.add(words(t, "flags", where)), lit(text(t, "link-emulation", where))); +} + +std::string provider_entry(const toml::Table& t, Arrays& arrays, const std::set& rows) { + known_keys(t, kProviderKeys, "[[platform.provider]]"); + const auto name = text(t, "name", "[[platform.provider]]"); + const auto where = std::format("[[platform.provider]] {}", name); + if (name.empty()) throw Failure{"[[platform.provider]]: an entry without `name`"}; + const auto serves = words(t, "serves", where); + if (serves.empty()) throw Failure{std::format("{}: `serves` is empty", where)}; + // Every identity a built-in provider serves is a built-in row, so a typo in + // either table fails here rather than as a target nobody can select. + for (auto const& s : serves) { + const bool any = std::ranges::any_of(rows, [&](auto const& r) { + return r == s || (s.starts_with("*-") && r.size() > s.size() - 1 + && r.ends_with(s.substr(1))); + }); + if (!any) throw Failure{std::format("{}: serves `{}`, which no row defines", where, s)}; + } + std::int64_t priority = 0; + if (auto it = t.find("priority"); it != t.end()) { + if (!it->second.is_int()) throw Failure{std::format("{}: `priority` is not an integer", where)}; + priority = it->second.as_int(); + } + const auto linkage = text(t, "linkage", where); + if (!linkage.empty() && linkage != "static" && linkage != "dynamic") + throw Failure{std::format("{}: linkage `{}` is not static or dynamic", where, linkage)}; + return std::format( + " {{ .name = {}, .serves = {}, .priority = {}, .hosts = {}, .probe = {}, .toolchain = {}, " + ".pairs = {}, .sysroot = {}, .linkage = {}, .platform = {} }},\n", + lit(name), arrays.add(serves), priority, arrays.add(words(t, "hosts", where)), + lit(text(t, "probe", where)), lit(text(t, "toolchain", where)), + arrays.add(words(t, "pairs", where)), lit(text(t, "sysroot", where)), lit(linkage), + lit(text(t, "platform", where))); +} + +const toml::Array& entries(const toml::Document& doc, std::string_view path) { + static const toml::Array none; + auto* v = doc.get(path); + if (!v) return none; + if (!v->is_array()) throw Failure{std::format("`{}` is not an array of tables", path)}; + return v->as_array(); +} + +std::string generate(const toml::Document& doc) { + Arrays arrays; + std::set rowNames; + std::string rows, providers; + for (auto const& e : entries(doc, "platform.define")) { + if (!e.is_table()) throw Failure{"[[platform.define]]: an entry that is not a table"}; + rows += row_entry(e.as_table(), arrays, rowNames); + } + for (auto const& e : entries(doc, "platform.provider")) { + if (!e.is_table()) throw Failure{"[[platform.provider]]: an entry that is not a table"}; + providers += provider_entry(e.as_table(), arrays, rowNames); + } + return std::format( + "// Generated by modules/toolchain-model/build.mcpp from data/builtin.toml.\n" + "export module mcpp.toolchain.builtin_targets;\n\n" + "import std;\n" + "import mcpp.toolchain.target_schema;\n\n" + "namespace mcpp::toolchain::builtin::d {{\n{}}}\n\n" + "export namespace mcpp::toolchain::builtin {{\n\n" + "inline constexpr mcpp::toolchain::target::Row kRows[] = {{\n{}}};\n\n" + "inline constexpr mcpp::toolchain::target::Provider kProviders[] = {{\n{}}};\n\n" + "}} // namespace mcpp::toolchain::builtin\n", + arrays.decls, rows, providers); +} + +} // namespace + +int main() { + const std::filesystem::path source = std::filesystem::path(mcpp::manifest_dir()) / "data" / "builtin.toml"; + mcpp::rerun_if_changed(source.generic_string().c_str()); + std::ifstream in(source, std::ios::binary); + if (!in) { + std::println(std::cerr, "build.mcpp: cannot read {}", source.generic_string()); + return 1; + } + const std::string src{std::istreambuf_iterator(in), {}}; + auto doc = toml::parse(src); + if (!doc) { + std::println(std::cerr, "{}:{}: {}", source.generic_string(), doc.error().where.line, + doc.error().message); + return 1; + } + std::string out; + try { + out = generate(*doc); + } catch (const Failure& f) { + std::println(std::cerr, "{}: {}", source.generic_string(), f.text); + return 1; + } + const auto target = std::filesystem::path(mcpp::out_dir()) / "builtin_targets.cppm"; + std::string was; + if (std::ifstream old(target, std::ios::binary); old) was.assign(std::istreambuf_iterator(old), {}); + if (was != out) { + std::filesystem::create_directories(target.parent_path()); + std::ofstream(target, std::ios::binary) << out; + } + mcpp::generated(target.generic_string().c_str()); + return 0; +} diff --git a/modules/toolchain-model/data/builtin.toml b/modules/toolchain-model/data/builtin.toml new file mode 100644 index 000000000..8b8e7afd7 --- /dev/null +++ b/modules/toolchain-model/data/builtin.toml @@ -0,0 +1,467 @@ +# THE BUILT-IN TARGETS: THE ROWS mcpp KNOWS AND THE PROVIDERS THAT SERVE THEM. +# +# This file is data, written in the schema a platform package uses for its own +# rows and providers (`[[platform.define]]`, `[[platform.provider]]`; SPEC-016). +# The engine holds the mechanism -- the axes, the selection rule, the protocols -- +# and this file holds the facts. `build.mcpp` compiles it into static tables of the +# engine, so a build reads no file and parses nothing to know them. +# +# A ROW defines an identity: the triple a build produces, the output directory, +# the cache key and every `cfg(...)` answer. The order of the rows is the order of +# `mcpp toolchain list`, of a target set's members, and the tie-break of selection. +# +# A PROVIDER serves identities: the toolchain, the C library and the rest of the +# target's side, and the hosts on which it is available. A request names a +# pattern (`x86_64-windows`); its candidates are the providers of the rows it +# matches, ordered by `priority` (larger first), then by row, then by name; the +# first available on the host is taken (SPEC-016 §4). Several entries may carry +# one name when one provider serves rows with different facts. +# +# Row keys +# triple the canonical identity, `arch-os[-env]` +# tier verified (CI builds and runs it) | preview (builds and links) | +# planned (registered intent; refused) +# note the display note of `mcpp toolchain list` +# standard the identity is an industry-standard one; a combination that +# is not (musl on Windows) is mcpp's name for what is produced +# march, mabi, code-model, multilib, flags, link-emulation +# the ISA profile of a bare-metal row: `-march`, `-mabi`, +# `-mcmodel` (empty: the driver's default), the multilib +# directory of a C library, extra compile flags, and the +# `ld.lld -m` emulation when clang's driver cannot reach lld +# +# Provider keys +# name the provider's name; `builtin:` selects it +# serves the identities it serves; `*` stands for the architecture +# priority the order among the candidates of one request +# hosts where its payload exists: `-` of the build machine, +# `*` for any segment, `{arch}` for the target's architecture +# probe an engine probe that must also answer (`msvc`: an installed +# Visual Studio toolset) +# toolchain the toolchain it pins; empty: the host's default +# pairs the compiler families it pairs with; empty: any family, and a +# declared family that does not supply the C library is refused +# sysroot the C library it brings (a payload), or `toolchain` when the +# toolchain ships the whole sysroot itself +# linkage the default linkage, `static` or `dynamic` +# platform a platform package that implements it (SPEC-013) + +# ── Linux ──────────────────────────────────────────────────────────────────── + +[[platform.define]] +triple = "x86_64-linux-gnu" +tier = "verified" +standard = true + +[[platform.define]] +triple = "x86_64-linux-musl" +tier = "verified" +standard = true + +[[platform.define]] +triple = "aarch64-linux-musl" +tier = "verified" +standard = true + +# ── Windows ────────────────────────────────────────────────────────────────── + +[[platform.define]] +triple = "x86_64-windows-gnu" +tier = "verified" +note = "PE" +standard = true + +# MUSL ON WINDOWS. No MinGW C runtime is linked into a build over openkal-musl +# (imports ntdll, KERNEL32, SHELL32; Itanium symbols only), so `gnu` would put the +# one thing the C library is not into the identity, the output directory, +# `cfg(env)` and the packed ABI tag. clang is still given `x86_64-w64-windows-gnu`, +# which selects the Itanium C++ ABI the C library was compiled for. +[[platform.define]] +triple = "x86_64-windows-musl" +tier = "preview" +note = "PE" +standard = false + +[[platform.define]] +triple = "x86_64-windows-msvc" +tier = "verified" +note = "PE" +standard = true + +# ── Apple, Linux continued ─────────────────────────────────────────────────── + +[[platform.define]] +triple = "aarch64-macos" +tier = "verified" +standard = true + +# MUSL ON macOS: openkal-platform's hosted part over openkal-macos. Its C library +# is openkal-musl rather than libSystem, which is what the env segment says. +[[platform.define]] +triple = "aarch64-macos-musl" +tier = "preview" +standard = false + +[[platform.define]] +triple = "riscv64-linux-musl" +tier = "planned" +standard = true + +# `preview` until the released engine is consumed on a clean ARM64 host +# (SPEC-009 §10.7). +[[platform.define]] +triple = "aarch64-linux-gnu" +tier = "preview" +standard = true + +[[platform.define]] +triple = "x86_64-macos" +tier = "planned" +standard = true + +[[platform.define]] +triple = "x86_64-macos-musl" +tier = "preview" +standard = false + +# ── Bare metal ─────────────────────────────────────────────────────────────── +# +# No loader, so static is the only linkage. The ISA profile is the row's: a +# correct object file from `--target ` alone, not every board. `x86-64` with +# a hyphen is the `-march` spelling of the `x86_64` segment; `aapcs` (not `lp64`) +# is the only `-mabi` aarch64 takes. `medany` because bare-metal RISC-V runs at +# 0x80000000, `small` for the low 2 GiB a `-kernel` image runs in; a higher-half +# kernel states `-mcmodel=kernel` itself. The multilib directory is +# `/` on RISC-V and the triple on ARM, where `-mabi` names the call +# standard and the float ABI is in the env segment. + +[[platform.define]] +triple = "riscv64-none-elf" +tier = "verified" +note = "bare" +standard = true +march = "rv64gc" +mabi = "lp64d" +code-model = "medany" +multilib = "rv64gc/lp64d" + +[[platform.define]] +triple = "riscv32-none-elf" +tier = "verified" +note = "bare" +standard = true +march = "rv32imac" +mabi = "ilp32" +code-model = "medany" +multilib = "rv32imac/ilp32" + +[[platform.define]] +triple = "aarch64-none-elf" +tier = "preview" +note = "bare" +standard = true +march = "armv8-a" +mabi = "aapcs" +code-model = "small" +multilib = "armv8-a/aapcs" + +# `-mno-red-zone`: on bare metal the processor pushes an interrupt frame into the +# 128 bytes below `rsp`, which a leaf function may use. clang's driver has no +# bare-metal x86_64 toolchain that reaches lld, so the row links with ld.lld +# directly under `elf_x86_64`. +[[platform.define]] +triple = "x86_64-none-elf" +tier = "preview" +note = "bare" +standard = true +march = "x86-64" +mabi = "sysv" +code-model = "small" +multilib = "x86-64/sysv" +flags = ["-mno-red-zone"] +link-emulation = "elf_x86_64" + +# `-mfpu=none` on the soft-float rows: the float ABI governs how values cross a +# call, not whether the compiler may use the FPU inside one (measured: `vmul.f32` +# under `thumbv7em-none-eabi`). + +[[platform.define]] +triple = "thumbv6m-none-eabi" +tier = "verified" +note = "bare" +standard = true +march = "armv6-m" +mabi = "aapcs" +multilib = "thumbv6m-none-eabi" +flags = ["-mfpu=none"] + +[[platform.define]] +triple = "thumbv7m-none-eabi" +tier = "verified" +note = "bare" +standard = true +march = "armv7-m" +mabi = "aapcs" +multilib = "thumbv7m-none-eabi" +flags = ["-mfpu=none"] + +[[platform.define]] +triple = "thumbv7em-none-eabi" +tier = "preview" +note = "bare" +standard = true +march = "armv7e-m" +mabi = "aapcs" +multilib = "thumbv7em-none-eabi" +flags = ["-mfpu=none"] + +[[platform.define]] +triple = "thumbv7em-none-eabihf" +tier = "verified" +note = "bare" +standard = true +march = "armv7e-m" +mabi = "aapcs" +multilib = "thumbv7em-none-eabihf" + +[[platform.define]] +triple = "thumbv8m.base-none-eabi" +tier = "preview" +note = "bare" +standard = true +march = "armv8-m.base" +mabi = "aapcs" +multilib = "thumbv8m.base-none-eabi" +flags = ["-mfpu=none"] + +[[platform.define]] +triple = "thumbv8m.main-none-eabi" +tier = "verified" +note = "bare" +standard = true +march = "armv8-m.main" +mabi = "aapcs" +multilib = "thumbv8m.main-none-eabi" +flags = ["-mfpu=none"] + +[[platform.define]] +triple = "thumbv8m.main-none-eabihf" +tier = "preview" +note = "bare" +standard = true +march = "armv8-m.main" +mabi = "aapcs" +multilib = "thumbv8m.main-none-eabihf" + +[[platform.define]] +triple = "armv7a-none-eabi" +tier = "verified" +note = "bare" +standard = true +march = "armv7-a" +mabi = "aapcs" +multilib = "armv7a-none-eabi" +flags = ["-mfpu=none"] + +[[platform.define]] +triple = "armv7a-none-eabihf" +tier = "verified" +note = "bare" +standard = true +march = "armv7-a" +mabi = "aapcs" +multilib = "armv7a-none-eabihf" + +# 32-BIT x86 (mcpp#911 G12). The code model is the driver's: clang refuses +# `-mcmodel=small` for every i386 spelling. clang's driver reaches no lld for a +# bare-metal x86 triple, so the row links with ld.lld directly under `elf_i386`. +[[platform.define]] +triple = "i686-none-elf" +tier = "preview" +note = "bare" +standard = true +march = "i686" +link-emulation = "elf_i386" + +# ── Android, iOS, the Web, ok images ───────────────────────────────────────── + +[[platform.define]] +triple = "aarch64-linux-android" +tier = "verified" +standard = true + +[[platform.define]] +triple = "x86_64-linux-android" +tier = "verified" +standard = true + +[[platform.define]] +triple = "aarch64-ios" +tier = "preview" +standard = true + +[[platform.define]] +triple = "aarch64-ios-sim" +tier = "verified" +standard = true + +[[platform.define]] +triple = "x86_64-ios-sim" +tier = "verified" +standard = true + +# A new arch, a new os and a new object format; `em++` links `import std` with +# the standard library surface the toolchain ships. One module plus its +# JavaScript, no shared objects. +[[platform.define]] +triple = "wasm32-emscripten" +tier = "verified" +note = "wasm" +standard = true + +# OK IMAGES (openkal SPEC clause 12): one artifact per architecture, run by +# `okrun` on Linux, macOS and Windows of that architecture. +[[platform.define]] +triple = "x86_64-openkal" +tier = "verified" +note = "ok" +standard = true + +[[platform.define]] +triple = "aarch64-openkal" +tier = "verified" +note = "ok" +standard = true + +# ── Providers ──────────────────────────────────────────────────────────────── +# +# Priorities: a system's own toolchain and C library 100; openkal-platform, +# which serves the musl identities of every system, 50; other conventions 10. + +# glibc from the payload, at the host's default toolchain natively. A cross to +# another architecture's glibc pins llvm; the payload exists for the host's arch. +[[platform.provider]] +name = "glibc" +serves = ["x86_64-linux-gnu"] +priority = 100 +hosts = ["linux-{arch}"] + +[[platform.provider]] +name = "glibc" +serves = ["aarch64-linux-gnu"] +priority = 100 +hosts = ["linux-{arch}"] +toolchain = "llvm@23.1.3" + +# The musl cross payloads: native on each Linux arch, every arch from x86_64, +# and the Canadian cross on a Windows host of the same arch. +[[platform.provider]] +name = "musl-gcc" +serves = ["x86_64-linux-musl", "aarch64-linux-musl"] +priority = 100 +hosts = ["linux-{arch}", "linux-x86_64", "windows-{arch}"] +toolchain = "gcc@16.1.0" +linkage = "static" + +[[platform.provider]] +name = "musl-gcc" +serves = ["riscv64-linux-musl"] +priority = 100 +hosts = ["linux-{arch}", "linux-x86_64"] +linkage = "static" + +# MinGW-w64: gcc, or clang with the payload as its sysroot (SPEC-016 §6). +[[platform.provider]] +name = "mingw" +serves = ["x86_64-windows-gnu"] +priority = 10 +hosts = ["windows-*", "linux-x86_64"] +toolchain = "gcc@16.1.0" +pairs = ["gcc", "llvm"] +linkage = "static" + +[[platform.provider]] +name = "msvc" +serves = ["x86_64-windows-msvc"] +priority = 100 +hosts = ["windows-*"] +probe = "msvc" + +[[platform.provider]] +name = "apple" +serves = ["aarch64-macos", "x86_64-macos"] +priority = 100 +hosts = ["macos-*"] + +[[platform.provider]] +name = "apple" +serves = ["aarch64-ios", "aarch64-ios-sim", "x86_64-ios-sim"] +priority = 100 +hosts = ["macos-*"] +toolchain = "llvm@23.1.3" + +# The C library of a bare-metal RISC-V row is the target's, like the compiler: +# no bare-metal package has to name one. +[[platform.provider]] +name = "bare-metal" +serves = ["riscv64-none-elf", "riscv32-none-elf"] +priority = 100 +hosts = ["*"] +toolchain = "llvm@23.1.3" +pairs = ["llvm"] +sysroot = "xim:picolibc-riscv@1.8.12" +linkage = "static" + +[[platform.provider]] +name = "bare-metal" +serves = ["aarch64-none-elf", "x86_64-none-elf", "i686-none-elf", "thumbv6m-none-eabi", + "thumbv7m-none-eabi", "thumbv7em-none-eabi", "thumbv7em-none-eabihf", + "thumbv8m.base-none-eabi", "thumbv8m.main-none-eabi", "thumbv8m.main-none-eabihf", + "armv7a-none-eabi", "armv7a-none-eabihf"] +priority = 100 +hosts = ["*"] +toolchain = "llvm@23.1.3" +pairs = ["llvm"] +linkage = "static" + +# bionic is inside the NDK; no package adds it to another compiler. +[[platform.provider]] +name = "android-ndk" +serves = ["*-linux-android"] +priority = 100 +hosts = ["*"] +toolchain = "android-ndk@30.0.16248370" +pairs = ["android-ndk"] +sysroot = "toolchain" + +[[platform.provider]] +name = "emsdk" +serves = ["wasm32-emscripten"] +priority = 100 +hosts = ["*"] +toolchain = "emsdk@6.0.9" +pairs = ["emsdk"] +sysroot = "toolchain" +linkage = "static" + +# openkal-platform: the ok images, and through its hosted part the musl identity +# of each system it implements (its C library is openkal-musl). +[[platform.provider]] +name = "openkal" +serves = ["*-openkal"] +priority = 100 +hosts = ["*"] +toolchain = "llvm@23.1.3" +pairs = ["llvm"] +linkage = "static" +platform = "mcpplibs.openkal-platform@0.24.4" + +[[platform.provider]] +name = "openkal" +serves = ["x86_64-windows-musl", "*-linux-musl", "*-macos-musl"] +priority = 50 +hosts = ["*"] +toolchain = "llvm@23.1.3" +pairs = ["llvm"] +linkage = "static" +platform = "mcpplibs.openkal-platform@0.24.4" diff --git a/modules/toolchain-model/mcpp.toml b/modules/toolchain-model/mcpp.toml index a2bf5ff90..eaf249c82 100644 --- a/modules/toolchain-model/mcpp.toml +++ b/modules/toolchain-model/mcpp.toml @@ -30,3 +30,8 @@ mcpplibs.os = "0.4.0" # layers must agree on. [dev-dependencies] compat.gtest = "1.15.2" + +# build.mcpp compiles data/builtin.toml (the built-in target rows and providers) +# into a module of static tables, with mcpp's own TOML parser. +[build-dependencies] +mcpp.libs = { path = "../libs" } diff --git a/modules/toolchain-model/src/target_schema.cppm b/modules/toolchain-model/src/target_schema.cppm new file mode 100644 index 000000000..5bddc6424 --- /dev/null +++ b/modules/toolchain-model/src/target_schema.cppm @@ -0,0 +1,98 @@ +// mcpp.toolchain.target_schema — the shape of a target row and of a provider. +// +// One schema for both sources of rows and providers: the built-in table +// (data/builtin.toml, compiled into mcpp.toolchain.builtin_targets by this +// package's build.mcpp) and a platform package's `[[platform.define]]` and +// `[[platform.provider]]` (SPEC-016). A built-in entry views static storage; an +// entry read from a platform package views the strings of the definition set +// that owns it. + +export module mcpp.toolchain.target_schema; + +import std; + +export namespace mcpp::toolchain::target { + +// A ROW DEFINES AN IDENTITY (SPEC-016 §2). The fields after `standard` are the +// facts of a row that the engine's conventions for a built-in system do not +// already answer: the ISA profile of a bare-metal row, and -- for a row a +// platform defines -- everything a build of an unfamiliar system needs. +struct Row { + std::string_view triple; // canonical `arch-os[-env]` + std::string_view tier; // verified | preview | planned + std::string_view note; // display note + bool standard = true; // an industry-standard identity + std::string_view march; // -march= of a bare-metal row + std::string_view mabi; // -mabi=; empty: none given + std::string_view codeModel; // -mcmodel=; empty: the driver's + std::string_view multilib; // a C library's multilib directory + std::span flags; // extra compile flags + std::string_view linkEmulation; // `ld.lld -m`, when the driver reaches no lld + // The facts of a defined row (a platform's). Empty on a built-in row, + // whose system's conventions answer them. + std::string_view clangTriple; // `{version}` / `{version:}` + std::string_view objectFormat; // elf | mach-o | pe | wasm + std::string_view family; // unix | windows | "" + std::string_view dataModel; // lp64 | llp64 | ilp32 + std::string_view exeSuffix; + std::string_view sharedSuffix; + std::string_view loader; // the program interpreter a dynamic program names + std::span descriptorKeys; + // The system the artifact runs on: the row's os by default, `loader` for an + // image a loader binds, `none` for bare metal. + std::string_view execution; + bool defined = false; // stated by a platform package +}; + +// A PROVIDER SERVES IDENTITIES (SPEC-016 §3). Several entries may share a name +// when one provider serves rows whose facts differ. +struct Provider { + std::string_view name; + std::span serves; // `*` stands for the arch + int priority = 0; + std::span hosts; // `-`, `*`, `{arch}` + std::string_view probe; // msvc + std::string_view toolchain; // empty: the host's default + std::span pairs; // empty: any family + std::string_view sysroot; // a payload, or `toolchain` + std::string_view linkage; // static | dynamic + std::string_view platform; // a platform package reference +}; + +// THE PATTERN OF `serves`. `*` in the first segment stands for any arch; the +// rest of the pattern matches exactly. +constexpr bool serves_triple(std::string_view pattern, std::string_view triple) { + if (pattern == triple) return true; + if (!pattern.starts_with("*-")) return false; + const auto dash = triple.find('-'); + return dash != std::string_view::npos && triple.substr(dash) == pattern.substr(1); +} + +constexpr bool serves(const Provider& p, std::string_view triple) { + for (auto s : p.serves) + if (serves_triple(s, triple)) return true; + return false; +} + +// WHERE A PROVIDER'S PAYLOAD EXISTS. `host` is `-` of the build machine; +// a pattern segment `*` matches anything, `{arch}` the target's architecture. +constexpr bool host_matches(std::string_view pattern, std::string_view host, + std::string_view targetArch) { + if (pattern == "*") return true; + const auto pd = pattern.find('-'); + const auto hd = host.find('-'); + if (pd == std::string_view::npos || hd == std::string_view::npos) return pattern == host; + const auto po = pattern.substr(0, pd), pa = pattern.substr(pd + 1); + const auto ho = host.substr(0, hd), ha = host.substr(hd + 1); + const bool os = po == "*" || po == ho; + const bool arch = pa == "*" || pa == ha || (pa == "{arch}" && ha == targetArch); + return os && arch; +} + +constexpr bool available_on(const Provider& p, std::string_view host, std::string_view targetArch) { + for (auto h : p.hosts) + if (host_matches(h, host, targetArch)) return true; + return false; +} + +} // namespace mcpp::toolchain::target diff --git a/modules/toolchain-model/src/triple.cppm b/modules/toolchain-model/src/triple.cppm index 58856106b..aba05d67e 100644 --- a/modules/toolchain-model/src/triple.cppm +++ b/modules/toolchain-model/src/triple.cppm @@ -27,6 +27,8 @@ export module mcpp.toolchain.triple; import std; import mcpplibs.os.platform; import mcpp.toolchain.platform; +import mcpp.toolchain.target_schema; +import mcpp.toolchain.builtin_targets; export namespace mcpp::toolchain::triple { @@ -286,9 +288,7 @@ struct Triple { // because the target decides the payload (see registry.cppm's // to_xim_package). If a row ever gains a second toolchain that does NOT // ship a sysroot, this has to move onto the toolchain. - bool has_own_sysroot() const { - return os == "emscripten" || os == "android" || env == "android"; - } + bool has_own_sysroot() const; // after the table: the row's provider answers // APPLE, AS ONE QUESTION. `os == "macos"` was the whole of it while macOS // was the only Apple row; iOS shares the object format, the linker, the @@ -377,10 +377,7 @@ struct Triple { // serve the row. `-D__BIONIC_CTYPE_INLINE=` and an API level fused onto // the triple are the NDK's own requirements, not flags a user can be // expected to supply to a different compiler. - bool pin_is_capability() const { - return is_freestanding() || is_openkal() || (is_pe() && is_musl()) || is_wasm() - || is_android(); - } + bool pin_is_capability() const; // after the table: the row's provider answers // cfg() `family` dimension: unix | windows. // @@ -490,429 +487,72 @@ struct TargetInfo { // another with `[target.] platform`, a machine in config.toml, // one invocation with `--platform`. std::string_view platform = {}; + // The row this view is of, and its default provider (null: none serves it). + const mcpp::toolchain::target::Row* row = nullptr; + const mcpp::toolchain::target::Provider* provider = nullptr; }; +// THE TABLE IS DATA (design 2026-11-09; SPEC-016). The rows and the providers +// that serve them are data/builtin.toml, compiled into +// mcpp.toolchain.builtin_targets by this package's build.mcpp; what this view +// adds is each row's DEFAULT provider -- the first built-in provider serving it, +// by priority and then by order -- whose toolchain, C library, linkage and +// platform are the columns every consumer of the table reads. Which provider a +// build USES is a selection (mcpp.toolchain.target_select), which also asks +// whether each candidate is available on the host; this view does not. +// // (note deliberately excludes "static" — the display layer derives that tag // from defaultStatic, so listing it here would duplicate it.) -inline constexpr TargetInfo kKnownTargets[] = { - // canonical tier note pin sysroot defaultStatic - { "x86_64-linux-gnu", "verified", "", "", "", false }, - { "x86_64-linux-musl", "verified", "", "gcc@16.1.0", "", true }, - { "aarch64-linux-musl", "verified", "", "gcc@16.1.0", "", true }, - { "x86_64-windows-gnu", "verified", "PE", "gcc@16.1.0", "", true }, - // musl ON WINDOWS. IT EXISTS, AND UNTIL THIS ROW mcpp HAD NO NAME FOR IT. - // - // LLVM's triple vocabulary offers `gnu` and `msvc` for Windows and both - // name an ABI, so a reader concludes there is no third possibility and - // calls a musl-based Windows build `-gnu`. The artefact disagrees. Measured - // on one built over `openkal-musl`: - // - // imports ntdll, KERNEL32, SHELL32 — no msvcrt, no ucrtbase - // `_Z…` symbols 4507 `?…` symbols 0 - // - // No MinGW C runtime is linked. That is musl on Windows, and calling it - // `gnu` put the one thing the C library is not into its identity, its - // output directory, its `cfg(env = …)` and its packed ABI tag. - // - // THE FIX IS A NAME, NOT A MECHANISM, AND THE REASON THE MISTAKE HELD SO - // LONG IS WORTH RECORDING. "LLVM cannot spell x86_64-windows-musl" is true - // and is about the string handed to CLANG. mcpp's canonical form is a - // different string — the build report prints both, either side of an arrow: - // - // Target x86_64-windows-gnu → x86_64-w64-windows-gnu - // ^ mcpp's identity ^ what clang is given - // - // Letting the compiler's vocabulary bound mcpp's own merged two axes into - // one. `llvm_triple()` already sends every non-MSVC Windows target to - // `…-w64-windows-gnu`, and that spelling is correct there and stays: it - // selects the Itanium C++ ABI, which is the ABI this C library was compiled - // for. mcpp's name answers a different question — which C library — and now - // it can. - // - // mcpp target → clang c-abi - // x86_64-linux-musl → x86_64-unknown-linux-musl musl - // x86_64-windows-gnu → x86_64-w64-windows-gnu MinGW CRT - // x86_64-windows-musl → x86_64-w64-windows-gnu musl - // - // The last two rows differ in the first column and agree in the second, - // which is the whole point. - // - // THE PIN IS `llvm`, AND IT IS NOT A PREFERENCE. The column names the - // payload that supplies this target's C library everywhere else in this - // table; here nothing supplies it, and what the column has to prevent is - // the OPPOSITE — a global default of gcc being carried onto a target no gcc - // can emit. Measured with `mcpp toolchain default gcc@16.1.0`: - // - // error: toolchain payload 'xim:musl-gcc@16.1.0' has no known C++ - // frontend in …/xim-x-musl-gcc/16.1.0/bin - // - // — a message about a missing frontend, for a target whose real problem is - // that only clang emits it at all. The bare-metal rows carry `llvm` for the - // same reason and say so in their own note. - // - // The C library still comes from the dependency graph. `host_can_serve` - // says no for this row on a non-Windows host — correctly, for the prebuilt - // system — and that refusal is diagnosed early and RELEASED once the graph - // is known (see the long note at prepare.cppm's `unservedTargetDiagnosis`), - // so a project whose C library comes from a dependency is not turned away. - // - // TIER IS `preview`, NOT `verified`. `verified` in this table means an - // artefact was built AND RUN. Running a PE on a Linux host needs wine, and - // openkal's CI has that step — so this is measurable, and the tier moves - // when it has been measured rather than when it seems likely. - { "x86_64-windows-musl", "preview", "PE", "llvm@23.1.3","", true }, - { "x86_64-windows-msvc", "verified", "PE", "", "", false }, - { "aarch64-macos", "verified", "", "", "", false }, - { "riscv64-linux-musl", "planned", "", "", "", true }, - // `preview` until the released engine is consumed on a clean ARM64 host - // (SPEC-009 §10.7): CI built and ran the native LLVM 23.1.3/glibc default - // from source, and the published chain (xlings client, glibc revision 3, - // the ARM LLVM resources, the CN mirrors) is what that consumption checks. - { "aarch64-linux-gnu", "preview", "", "llvm@23.1.3","", false }, - { "x86_64-macos", "planned", "", "", "", false }, - // Bare metal. `defaultStatic` is not a preference here — there is no - // loader, so there is no other option. The pin is llvm on every host - // because clang/lld are cross-compilers by construction: unlike the hosted - // rows above, these need no per-host cross payload at all. - // ISA profile (-march/-mabi/-mcmodel) lives in mcpp.freestanding.target, - // which is the single place that decision is made. - // The sysroot column is what keeps a bare-metal PACKAGE from having to - // name a libc: the C library is the target's, like the compiler. - { "riscv64-none-elf", "verified", "bare","llvm@23.1.3","xim:picolibc-riscv@1.8.12", true }, - { "riscv32-none-elf", "verified", "bare","llvm@23.1.3","xim:picolibc-riscv@1.8.12", true }, - // AN EMPTY SYSROOT COLUMN, AND IT IS A STATEMENT RATHER THAN AN OMISSION. - // - // The two rows above name a C library because a project targeting them - // ordinarily wants one. This row does not, because there is no aarch64 - // build of picolibc in the index — and, more to the point, because the - // first consumer of this row does not want one. `openarch` is a layer of - // machine mechanism: contexts, traps, page-table entries. It references no - // C library symbol, and a row that resolved one would make every project - // on this target carry a payload it never calls. - // - // An empty column here means exactly what `[target.].sysroot = ""` - // means in a manifest — the zero-libc tier: no headers on the compile line, - // no library directory on the link, and `#include ` does not - // resolve. A project that wants a C library on this target says so in its - // own manifest, which is also how it would choose a different one. - // - // The tier is `preview` and not `verified`: `verified` in this table - // means an image has been built AND RUN for the row, and running one needs - // an emulator. `xim:qemu-arm` provides `qemu-system-aarch64`; until a probe - // has actually booted under it, claiming `verified` would be claiming the - // measurement rather than reporting it. - { "aarch64-none-elf", "preview", "bare","llvm@23.1.3","", true }, - // THIS ROW EXISTS SO THAT A THIRD MACHINE CAN DISAGREE WITH THE FIRST - // TWO, WHICH IS THE ONLY THING THAT TELLS AN ABSTRACTION FROM A HABIT. - // - // riscv64 and aarch64 are both load/store RISC machines with a weak memory - // model and a fixed instruction width, so an interface that fits both may - // fit because it is right or because they are alike. x86_64 is neither: it - // has variable-length instructions, a total-store-order memory model under - // which three of openarch's four barriers need no instruction at all, and - // an interrupt mechanism that is a table of gates rather than a base - // register. What survives all three is an abstraction. - // - // The tier is `preview` for the same reason aarch64's is, and the reason - // is stricter than it sounds: `verified` here means an image was built AND - // RUN. `xim:qemu-x86` does not exist yet — the index carries no - // `qemu-system-x86_64` — so nothing on this row has booted. Claiming - // `verified` would be claiming a measurement that has not been made. - // - // The sysroot column is empty, the zero-libc tier, for the reason given - // above `aarch64-none-elf`: the first consumer is `openarch`, which - // references no C library symbol. - { "x86_64-none-elf", "preview", "bare","llvm@23.1.3","", true }, - // ── Cortex-M ──────────────────────────────────────────────────────────── - // - // SEVEN ROWS AND NOT ONE, BECAUSE "Cortex-M" IS NOT AN INSTRUCTION SET. - // - // Every other bare-metal family here is one row per architecture. M-profile - // is not: an object built for `thumbv7em` uses instructions a Cortex-M0 - // does not have, and one built for `thumbv6m` runs on both but leaves the - // larger part unused. The two spellings are not a preference a board - // expresses — they produce incompatible objects — so they are rows. - // - // The rule this table states about itself governs: it exists so that - // `--target ` ALONE is enough to produce a correct object file. A - // single `arm-none-eabi` row plus an `-mcpu` the project remembers would - // move a correctness decision out of the table and into every manifest. - // - // THE `eabi`/`eabihf` SUFFIX IS THE FLOAT ABI, AND CLANG ALREADY READS - // IT. Measured on llvm 22.1.8 (`-###`, `-cc1` line): `thumbv7em-none-eabi` - // gives `-mfloat-abi soft` and `-none-eabihf` gives `hard`, with no flag - // from us. So the ABI needs no entry in the ISA table's `extra` column — - // only the FPU does, and only on the soft rows. See `kThumbSoftExtra`. - // - // `sysroot` IS EMPTY ON EVERY ROW, AND THAT IS THE POINT RATHER THAN A - // GAP. The three older bare-metal families name an `xim:` payload here; a C - // library for these targets arrives from the DEPENDENCY GRAPH instead - // (`mcpp:c-abi=picolibc`, docs/14). A prebuilt payload would have to ship - // one multilib per row — seven here — and the `libdir` column would have to - // match its layout byte for byte, which is the defect #481 fixed. A source - // package is compiled with the consuming target's own flags, so the ABI - // agreement holds by construction and there is no multilib at all. - // - // THE TIER COLUMN RECORDS WHAT WAS RUN, NOT WHAT WAS REASONED. Measured - // 2026-09-04 under `xim:qemu-arm@9.2.4-1`: each `verified` row below built - // an image that BOOTED on the named machine and printed over semihosting — - // thumbv6m on `microbit`, thumbv7m on `mps2-an385`, thumbv7em-eabihf on - // `mps2-an386`, thumbv8m.main-eabi on `mps2-an505`. The three `preview` - // rows build and link; no emulator run has been recorded for them. - { "thumbv6m-none-eabi", "verified", "bare","llvm@23.1.3","", true }, - { "thumbv7m-none-eabi", "verified", "bare","llvm@23.1.3","", true }, - { "thumbv7em-none-eabi", "preview", "bare","llvm@23.1.3","", true }, - { "thumbv7em-none-eabihf", "verified", "bare","llvm@23.1.3","", true }, - { "thumbv8m.base-none-eabi","preview", "bare","llvm@23.1.3","", true }, - { "thumbv8m.main-none-eabi","verified", "bare","llvm@23.1.3","", true }, - { "thumbv8m.main-none-eabihf","preview","bare","llvm@23.1.3","", true }, - // ── ARMv7-A (Cortex-A, 32-bit) ────────────────────────────────────────── - // - // NOT A SECOND SPELLING OF THE M ROWS. A-profile has a memory management - // unit and a page-table walker; M-profile has an MPU and no page-table - // entry at all. It is the first 32-bit machine in this table on which an - // address space can be described, which is precisely the question the - // openarch layer has never been able to ask of a 32-bit target. - // - // `verified` records what was RUN. Measured 2026-09-04 under - // `xim:qemu-arm@9.2.4-1`: both rows booted on `-M virt -cpu cortex-a15` and - // printed over semihosting. The soft row carries `-mfpu=none` for the - // reason `kThumbSoftExtra` gives, measured again on this architecture - // rather than carried over from M-profile. - // - // `sysroot` is empty, the zero-libc tier, exactly as for the M rows: a C - // library for these targets arrives from the dependency graph. - { "armv7a-none-eabi", "verified", "bare","llvm@23.1.3","", true }, - { "armv7a-none-eabihf", "verified", "bare","llvm@23.1.3","", true }, +constexpr const mcpp::toolchain::target::Provider* default_provider_of(std::string_view triple) { + const mcpp::toolchain::target::Provider* best = nullptr; + for (auto const& p : mcpp::toolchain::builtin::kProviders) + if (mcpp::toolchain::target::serves(p, triple) && (!best || p.priority > best->priority)) + best = &p; + return best; +} - // ── The three platforms a package cannot add ──────────────────────────── - // - // A package can add a language, a tool, an action, a payload and a - // generated module. IT CANNOT ADD A TRIPLE: identity is these three - // strings and this table is compiled into the binary, so every layer above - // -- the `.apk` step, the `.app` step, the `.html`+`.wasm` step, the - // runner, the signing -- waits on a row here and on nothing else in the - // engine. Registering the rows is what turns each of those into a plugin - // that can be written rather than a plugin that has nowhere to attach. - // - // ALL FOUR ARE `planned`, WHICH IS A REFUSAL AND NOT A GAP. The tier gate - // refuses a planned row with `tier-planned` naming the row, so - // `mcpp build --target aarch64-linux-android` says the vocabulary has this - // target and nothing is wired yet -- rather than `unknown target`, which - // was false, or a build that resolves and produces nothing, which would be - // worse than either. What each row still needs is recorded in - // .agents/docs/2026-09-11-distribution-plugins-and-platform-decomposition.md - // section 3, and it is a payload in every case, never engine work. - // - // THE PREREQUISITE NOBODY LISTS IS ANSWERED FOR TWO OF THE THREE. mcpp is - // module-first, so a row whose toolchain cannot compile a module interface - // unit would be worse than its absence. Measured 2026-09-11: `import std` - // works on both the NDK's clang 18 and Emscripten's, and neither needs a - // fork or a compiler upgrade -- what both need is the generated module - // surface their vendor chose not to install (133 files, 620 KB, taken from - // the libc++ revision matching `_LIBCPP_VERSION`, which for Emscripten is - // NOT the version its clang reports). Apple's half is not measurable on a - // Linux host and is the one genuinely open question of the three. - - // ANDROID IS THE SMALLEST OF THE THREE, and the ranking is the opposite of - // the demand ranking. `aarch64` is already an arch, ELF is already the - // object format, and Linux is already the OS: what was missing is an `env` - // value and a sysroot that points at an NDK. No `pin`, because no cross - // payload exists yet -- `xim:android-ndk` is the row's whole remaining - // cost, and until it lands `[target.].sysroot` is the escape hatch - // for a machine that has an NDK already. - { "aarch64-linux-android", "verified", "", "android-ndk@30.0.16248370", "", false }, - // The emulator's row, and the one of the pair that could be EXECUTED. - // - // Not a convenience: x86_64 is what an Android emulator image runs, so a - // row for the device without one for the emulator describes a target - // nothing can execute. That argument is now measured rather than asserted. - // 2026-09-11, linux-x86_64, an API 24 x86_64 system image under the - // platform's own emulator with KVM: - // - // adb push /data/local/tmp/ - // adb shell ./andtest -> 1-2-3 exit 0 - // - // from `import std;` and no project vocabulary beyond `--target`. So this - // row is `verified` while `aarch64-linux-android` is `preview`, and the - // difference is execution rather than confidence in the build. - // - // AND THE DEVICE ROW FOLLOWED, BY A DIFFERENT VEHICLE. Google's emulator - // refuses a foreign guest outright -- "QEMU2 emulator does not support - // arm64 CPU architecture" -- so the arm64 image needs an arm64 host. The - // route that works from an x86_64 one is qemu-user over the system image's - // own bionic, and what blocked it was never the route: preparing it needs - // four files out of an ext4 partition image, `debugfs` was the tool, and - // `xim:e2fsprogs@1.47.3`'s debugfs is a broken build (SIGFPE on every - // filesystem-opening command, while dumpe2fs/e2fsck/tune2fs from the same - // payload work). `xim:7zip` reads ext4 directly. Measured 2026-09-11: - // - // 7zz x bin/linker64 lib64/lib{c,dl,m,c++}.so - // qemu-aarch64-static -L - // -> 1-2-3 exit 0 - // - // So both rows are `verified` and the vehicles differ: the platform's own - // emulator for x86_64, qemu-user plus the image's bionic for aarch64. A - // tier states that an artefact was built and RUN, not which emulator ran - // it. - // - // One linker warning is worth recording because a user will see it and it - // is not a defect: `unsupported flags DT_FLAGS_1=0x8000001`. API 24's - // bionic linker does not recognise the `DF_1_PIE` bit that lld sets, warns, - // and loads the program anyway. - { "x86_64-linux-android", "verified", "", "android-ndk@30.0.16248370", "", false }, - - // iOS IS NEXT. `aarch64-macos` is `verified`, so Mach-O, `arm64`, the - // linker and the Apple half of the toolchain model all exist; what is - // missing is an `os` value and the iPhoneOS SDK. - // - // THE COMPILER IS OURS AND ONLY THE SDK IS APPLE'S, which is the sentence - // that shrank this row from a packaging problem to a located directory. - // - // `llvm@23.1.3` -- any sufficiently new clang emits arm64 Mach-O for an - // iOS deployment target, and the C++ runtime comes from the SDK the way it - // does on every Apple platform. `aarch64-macos` is verified on exactly - // this split and is the precedent: `xim:llvm` compiles and the SDK is - // located. Xcode's own clang would also work and is the wrong default -- - // it would make the compiler a host dependency where the ecosystem - // already has one, and these would be the only Apple rows not using - // `xim:llvm`. - // - // A CONVENTION PIN, NOT A CAPABILITY ONE, which is why - // `pin_is_capability()` does not name these rows. It means "this is what - // `--target aarch64-ios` resolves when the project says nothing", and a - // project may still write `[target.aarch64-ios] toolchain = "..."`. The - // capability set is the rows NO other payload can serve, and the - // distinction matters for consistency with `aarch64-macos`: the same - // constraint (Darwin needs clang) holds there and that row is not a - // capability row either. - // - // THE SDK IS A LICENCE QUESTION AND NOT A PACKAGING ONE, which is why this - // row carries no `sysroot`. The NDK is Apache-2.0 and Emscripten is MIT, - // both redistributable; the iPhoneOS SDK is neither. The recipe should - // reach for the lowest of three tiers its licence allows -- redistribute, - // fetch from upstream without a mirror, or locate what the machine already - // has -- and say which tier it took, because a consumer reading "locator" - // needs to know that is a licence conclusion rather than an unfinished - // recipe. `msvc@system` is the shape of the third tier and mcpp already - // has it. - // - // The simulator is deliberately not a row. It has its own SDK and produces - // its own object, so folding it in would make two targets share an - // identity -- the mistake `x86_64-windows-musl` was added to undo. - // - // `preview`: BUILT, AND NOT RUN, AND NOTHING HERE CAN RUN IT. Measured - // 2026-09-11 on macos-15 (Xcode 16.4, iPhoneOS 18.5, - // `ios_deployment_target = "18.0"`): - // - // Mach-O 64-bit executable arm64 - // LC_BUILD_VERSION platform 2 (IOS) minos 18.0 sdk 18.5 - // - // `platform 2` against the simulator rows' `platform 7` is the reading - // worth having: a successful build cannot tell them apart, and an artefact - // reporting IOSSIMULATOR from this row is one no later step refuses. - // - // The row keeps `runner` unset and will stay `preview`. An artefact cannot - // be run off an iOS device without a signature the developer owns, which - // is not something a build tool or a package can supply -- so this is a - // tier bounded by a fact about the platform rather than by work not yet - // done. - { "aarch64-ios", "preview", "", "llvm@23.1.3","", false }, - // THE SIMULATOR'S TWO ROWS. Not a convenience and not a runner: a - // simulator build has its own SDK (`iPhoneSimulator.sdk`), produces its own - // object, and takes `-mios-simulator-version-min` rather than - // `-miphoneos-version-min`. Two targets sharing one identity is what the - // device row's own comment objected to, and the objection was to the - // absence of these rows rather than to their presence. - // - // BOTH ARCHES, for the reason the Android pair has both: the simulator runs - // the HOST's architecture, so an Apple-silicon machine needs `aarch64` and - // an Intel one needs `x86_64`. A single row would describe a simulator half - // the machines cannot run. - // - // BOTH `verified`, AND THE SECOND ONE WAS RUN ON ANOTHER MACHINE. - // - // `aarch64-ios-sim` was built AND RUN. Measured 2026-09-11 on macos-15 - // (Xcode 16.4, iPhoneSimulator 18.5, `ios_deployment_target = "18.0"`): - // - // artefact Mach-O 64-bit executable arm64 - // LC_BUILD_VERSION platform 7 (IOSSIMULATOR) minos 18.0 - // run xcrun simctl spawn -> 1-2-3 - // - // with no bundle, no signature and no Info.plist -- the measurement that - // made `simctl-run` a boot-and-spawn wrapper rather than a bundle builder. - // - // `x86_64-ios-sim` builds and its artefact is correct -- Mach-O x86_64, - // the same `platform 7`, the same floor -- and a SIMULATOR RUNS THE HOST'S - // ARCHITECTURE, so the Apple-silicon runner that builds it cannot run it. - // ci-macos-ios.yml builds it there and hands the artefact to a - // `macos-15-intel` job, which spawns it on that host's simulator and reads - // `1-2-3` and status 0 (design 2026-10-23 §2.6, S15). The row can be built - // because the SDK carries an x86_64 slice, which is measured; whether the - // vendor still sells the hardware is not a property of the row. - { "aarch64-ios-sim", "verified", "", "llvm@23.1.3","", false }, - { "x86_64-ios-sim", "verified", "", "llvm@23.1.3","", false }, - - // WEB IS THE OUTLIER, AND IT IS THE ONLY ONE OF THE THREE THAT CHANGES THE - // MODEL RATHER THAN EXTENDING A TABLE. A new arch (`wasm32`), a new os - // (`emscripten`), and -- the sharp part -- a new OBJECT FORMAT, which - // before `ObjectFormat` existed was not a field at all but a derivation - // repeated at every site that needed it. That is why this is - // https://github.com/mcpp-community/mcpp/issues/597 and not a table row. - // - // IT IS NOW ONLY THAT, AND THE STANDARD-LIBRARY HALF IS SIMPLER THAN THIS - // COMMENT FIRST SAID. Measured 2026-09-11 against Emscripten 6.0.9: - // `em++` compiles and links `import std` with NO additional flags and no - // generated surface at all, because the toolchain SHIPS one -- 134 files - // -- and `node app.js` printed the expected output. - // - // The version numbers here were two releases stale, in exactly the - // direction the design record warns about: they said llvm 20.1.7 and - // `_LIBCPP_VERSION 200100` against clang 22.0.0git. Emscripten 6.0.9 - // reports `220108` (llvm 22.1.8) and clang 24.0.0git. The rule those - // numbers were supporting is unaffected and is the reason to keep them - // accurate: the surface must match the LIBRARY, never the compiler, and a - // recipe's job is to pin the `_LIBCPP_VERSION` it measured and refuse a - // change. A stale number in a comment becomes a stale number in a - // diagnostic, and then in somebody's install command. - // - // `defaultStatic` is true because wasm has no dynamic loader in the sense - // the other rows mean: an Emscripten link produces one module plus its - // JavaScript, and there is no shared object for a search path to find. - // - // `verified` ASSERTS THE WHOLE LOOP, and here is what it was measured - // against (2026-09-11, Linux x86_64, xim:emsdk 6.0.9): - // - // mcpp build --target wasm32-emscripten on a source that imports std - // -> bin/ 65389 bytes the JavaScript - // bin/.wasm 447183 bytes the module - // node bin/ -> 1-2-3 - // - // Reaching it took five engine gates, and each one was found by the - // previous one's failure rather than by reading: the payload had to be - // chosen by the TARGET (registry.cppm), the frontend found outside `bin/` - // (frontendSubdir), the host's header set withheld (has_own_sysroot, in - // the shared producer and not at one of its three callers), the C-runtime - // group withheld from the LINK MODEL rather than from its two channels, - // and -- the same finding a second time -- the COMPILER's own runtime - // directories kept off the ARTIFACT's link line. For every row that - // predates this one those two are the same directory. - { "wasm32-emscripten", "verified", "wasm","emsdk@6.0.9","", true }, - // OK IMAGES (openkal SPEC clause 12; design 2026-10-26 part 2, §8.5). One - // artifact per architecture, run by `okrun` on Linux, macOS and Windows of - // that architecture. clang emits it from one binary for either row. What - // makes it an ok image -- its flags, its link, its note and check, its - // name, its runner, its C library and C++ run-time -- is the platform's, - // `openkal-platform` (SPEC-013), pinned here. The two architectures the - // loader relocates. - { "x86_64-openkal", "verified", "ok", "llvm@23.1.3","", true, - "mcpplibs.openkal-platform@0.24.4" }, - { "aarch64-openkal", "verified", "ok", "llvm@23.1.3","", true, - "mcpplibs.openkal-platform@0.24.4" }, -}; +constexpr TargetInfo info_of(const mcpp::toolchain::target::Row& r) { + TargetInfo t{ r.triple, r.tier, r.note, {}, {}, false }; + t.row = &r; + t.provider = default_provider_of(r.triple); + if (t.provider) { + t.pin = t.provider->toolchain; + t.sysroot = t.provider->sysroot == "toolchain" ? std::string_view{} : t.provider->sysroot; + t.defaultStatic = t.provider->linkage == "static"; + t.platform = t.provider->platform; + } + return t; +} + +inline constexpr auto kKnownTargets = [] { + std::array out{}; + for (std::size_t i = 0; i < out.size(); ++i) out[i] = info_of(mcpp::toolchain::builtin::kRows[i]); + return out; +}(); inline std::span known_targets() { return kKnownTargets; } +// A PROVIDER WHOSE TOOLCHAIN SHIPS THE WHOLE SYSROOT (`sysroot = "toolchain"`): +// the Emscripten and Android SDKs, which resolve their sysroot themselves. A +// triple outside the table answers by its system, as before the table was data. +inline bool Triple::has_own_sysroot() const { + const auto s = str(); + for (auto const& k : kKnownTargets) + if (k.canonical == s) return k.provider && k.provider->sysroot == "toolchain"; + return os == "emscripten" || os == "android" || env == "android"; +} + +// A PIN IS A CAPABILITY WHEN THE PROVIDER PAIRS WITH ONE COMPILER FAMILY ONLY: +// nothing else emits the target (bare metal, ok images, PE with musl, wasm) or +// supplies its system (bionic). A provider that pairs with several families +// (MinGW: gcc and clang) states a convention among them. +inline bool Triple::pin_is_capability() const { + const auto s = str(); + for (auto const& k : kKnownTargets) + if (k.canonical == s) return k.provider && k.provider->pairs.size() == 1; + return is_freestanding() || is_openkal() || (is_pe() && is_musl()) || is_wasm() || is_android(); +} + inline const TargetInfo* find_known_target(const Triple& t) { auto s = t.str(); for (auto& k : kKnownTargets) @@ -1512,6 +1152,10 @@ std::optional parse(std::string_view s) { if (t.os == "ios" && (k == "sim" || k == "simulator")) { t.env = "sim"; t.envExplicit = true; continue; } + // musl ON macOS (openkal-platform's hosted part, whose C library is + // openkal-musl rather than libSystem). The native identity keeps no env + // segment; this one names the C library it has. + if (t.os == "macos" && k == "musl") { t.env = "musl"; t.envExplicit = true; continue; } // Unrecognized segment (wasi, …): not in mcpp's target language — // treat as unparseable rather than guessing. return std::nullopt; @@ -1521,7 +1165,7 @@ std::optional parse(std::string_view s) { // macOS carries no env segment at all, so nothing was declined there. iOS // and Emscripten are the same shape: the platform layer is the whole of the // identity past the arch, and there is no C-library axis to decline. - if (t.os == "macos" || t.os == "emscripten" || t.os == "openkal" + if ((t.os == "macos" && t.env != "musl") || t.os == "emscripten" || t.os == "openkal" || (t.os == "ios" && t.env != "sim")) { t.env.clear(); t.envExplicit = false; } diff --git a/src/freestanding/target.cppm b/src/freestanding/target.cppm index 17777fffe..74b3731d4 100644 --- a/src/freestanding/target.cppm +++ b/src/freestanding/target.cppm @@ -31,6 +31,7 @@ export module mcpp.freestanding.target; import std; import mcpp.toolchain.triple; +import mcpp.toolchain.builtin_targets; export namespace mcpp::freestanding { @@ -82,250 +83,26 @@ struct Spec { std::string_view lldEmulation; }; -// The flags each row's `extra` column points at. Named arrays rather than -// inline braces because a `span` must refer to storage that outlives it, and a -// temporary array in an aggregate initialiser does not. -inline constexpr std::string_view kX86_64NoneExtra[] = { - // WITHOUT THIS, EVERY TRAP HANDLER ON THIS TARGET CORRUPTS THE FUNCTION - // IT INTERRUPTED, AND NOTHING REPORTS IT. - // - // The System V x86-64 ABI reserves 128 bytes below `rsp` — the red zone — - // which a leaf function may use without adjusting the stack pointer, - // because on a hosted system nothing else writes there: the kernel switches - // to its own stack for interrupts, and signal frames are placed clear of - // it. - // - // On bare metal nothing does that for you. The processor pushes an - // interrupt frame at `rsp` — into the red zone — and the leaf function - // resumes to find its locals overwritten. There is no fault and no - // diagnostic; the value is simply wrong, and only sometimes, because it - // depends on whether an interrupt happened to arrive inside a leaf. - // - // This is a property of the target rather than of a project, which is why - // it is here: there is no bare-metal x86_64 program for which the red zone - // is safe. RISC-V and aarch64 have no equivalent, which is why the column - // did not exist until this row. - "-mno-red-zone", -}; - -// THE SOFT-FLOAT M-PROFILE ROWS, AND WHY THE FLOAT ABI IS NOT ENOUGH. -// -// `thumbv7em-none-eabi` and `-eabihf` differ in their float ABI, and clang -// derives that from the triple with no help from us (measured, `-###`: -// `-mfloat-abi soft` and `hard` respectively). It would be reasonable to stop -// there. Measured on llvm 22.1.8, that is wrong: -// -// clang --target=thumbv7em-none-eabi -O2 -S (float a*b+1.0f) -// vmul.f32 ← an FPU instruction -// -// The float ABI governs how floating-point values cross a function boundary. -// It does not govern whether the compiler may USE the FPU inside one, and the -// `thumbv7em` architecture implies FPv4-SP, so clang emits FPU instructions -// under either ABI. A plain Cortex-M4 — the part this row exists to serve, -// since the -eabihf row serves M4F — has no FPU, and the instruction faults. -// -// The failure is a hard fault at run time on real silicon, with a clean -// compile and a clean link. It is the shape this table exists to prevent. -// -// `-mfpu=none` restores the property the row's name claims. Measured: the -// instruction count returns to zero, and the `-eabihf` rows must NOT receive -// this flag — there it would discard the FPU the row exists to use. -// -// EVERY SOFT ROW CARRIES IT, INCLUDING THE ONES WHERE NOTHING WAS MEASURED. -// -// Only `thumbv7em-none-eabi` was observed emitting an FPU instruction; -// `thumbv6m`, `thumbv7m` and `thumbv8m.base` describe architectures with no FPU -// at all, and `thumbv8m.main-none-eabi` emits none today because its default -// CPU is `generic`. Applying the flag only where a count was non-zero would -// make the table record a MEASUREMENT rather than a GUARANTEE, and the rows -// would then differ for a reason no reader could see. -// -// A row states the property it promises. `-mfpu=none` is accepted on all four -// (measured), costs nothing where there is no FPU, and is what a unit test can -// quantify over — which is the only place this rule can be stated, since a -// build of any one row cannot speak for the others. -inline constexpr std::string_view kThumbSoftExtra[] = { - "-mfpu=none", -}; - -// AND THE SAME RULE ON A-PROFILE, WHERE IT WAS MEASURED SEPARATELY RATHER -// THAN ASSUMED TO CARRY OVER. -// -// `armv7a-none-eabi` is a different architecture from `thumbv7em`, so the -// M-profile measurement above says nothing about it. Measured on llvm 22.1.8, -// the same float multiply: -// -// clang --target=armv7a-none-eabi -march=armv7-a -O2 -S → 1 vmul/vmla -// …the same, with -mfpu=none → 0 -// -// A Cortex-A without a VFP is unusual and permitted, and a Cortex-R more so. -// The row states the property its name claims, exactly as the M rows do. -inline constexpr std::string_view kArmSoftExtra[] = { - "-mfpu=none", -}; - -// THE `libdir` COLUMN WAS EMPTY ON THE LAST TWO ROWS UNTIL 2026-08-21, AND -// THAT WAS CORRECT UNTIL THE DAY IT WAS NOT. -// -// It names the sub-directory of a multilib C library — picolibc's own -// convention, `/` — and those two rows had no C library in the -// index to point into, so an empty column said something true. -// -// `xim:picolibc-aarch64` and `xim:picolibc-x86` now exist. Measured with the -// column still empty: a project that declares -// `[target.aarch64-none-elf] sysroot = "xim:picolibc-aarch64@1.8.12"` gets -// `'stdio.h' file not found` — the package is installed and correct, and the -// engine cannot find the profile inside it. -// -// Filling this does NOT give those targets a C library by default. The -// column is consulted only when a sysroot has been resolved, and the target -// TABLE still binds none for these two rows — the zero-libc tier stays the -// default and the package stays opt-in. This makes the opt-in work; it does not -// take the opt-out away. -// -// Defaults, not the only possibility. `rv64gc/lp64d` is what qemu `virt` -// runs and what the first BSP targets; a board that needs `rv32imac/ilp32` -// selects it through its own manifest, not by editing this table. The table -// exists so that `--target riscv64-none-elf` alone is enough to produce a -// correct object file — not to enumerate every board. -inline constexpr Spec kTable[] = { - // triple march mabi mcmodel libdir - { "riscv64-none-elf", "rv64gc", "lp64d", "medany", "rv64gc/lp64d" }, - { "riscv32-none-elf", "rv32imac", "ilp32", "medany", "rv32imac/ilp32" }, - // `aapcs` AND NOT `lp64`. The two RISC-V rows above spell their ABI the - // way RISC-V does, and the obvious extrapolation to aarch64 is `lp64` — - // which is what LP64 aarch64 actually is, and which clang rejects: - // - // error: unknown target ABI 'lp64' - // - // aarch64's `-mabi` names a procedure call standard rather than a data - // model, and the only value it takes for this target is `aapcs`. Measured - // rather than reasoned: `-mabi=lp64` compiles on riscv64 and fails here, - // so a table filled in by analogy would have been wrong in exactly the - // place the analogy is strongest. - // - // `small` is the code model: it addresses ±4GiB PC-relative, which covers - // any single bare-metal image regardless of where the linker script places - // it. RISC-V needs `medany` for the corresponding reason — its default - // assumes the low 2GiB, and bare-metal RISC-V runs at 0x80000000. - // - // THE LIBDIR COLUMN IS EMPTY, AND THAT IS THE ROW'S CHARACTER. It names - // a C library's multilib directory, and this target's row in the target - // table resolves no C library: `aarch64-none-elf` is the zero-libc tier. - // The engine reads this column only when a sysroot exists, so an invented - // value would be a value nothing could ever check. - { "aarch64-none-elf", "armv8-a", "aapcs", "small", "armv8-a/aapcs" }, - // `x86-64` WITH A HYPHEN, WHICH IS THE ONE PLACE THIS TRIPLE'S TWO - // SPELLINGS DIVERGE. The triple segment is `x86_64` with an underscore and - // the `-march` value is `x86-64` with a hyphen; deriving one from the other - // is a substitution that looks harmless and produces `unknown target CPU`. - // - // `x86-64` is the baseline, not a modern microarchitecture level. A row - // that named `x86-64-v3` would produce images that fault on hardware and - // emulators older than roughly 2015, for a target whose whole audience is - // people who do not control what they run on. A project that knows its - // machine raises it in its own manifest. - // - // `sysv` is the only ABI this target has, and unlike aarch64's `aapcs` it - // is also the driver's default. It is written out because this table is the - // place the decision is made, not the place it is inherited. - // - // `small` places code and data in the low 2 GiB, which is where a - // `-kernel` image loaded by an emulator runs. A higher-half kernel — one - // linked at 0xFFFFFFFF80000000 — selects `-mcmodel=kernel` in its own - // manifest; that is a linker-script decision and cannot be a default, - // because the two are wrong for each other rather than merely suboptimal. - // - // THE LIBDIR IS EMPTY FOR THE SAME REASON aarch64's IS: this row - // resolves no C library, so a value here could never be checked. - { "x86_64-none-elf", "x86-64", "sysv", "small", "x86-64/sysv", kX86_64NoneExtra, - "elf_x86_64" }, - // ── Cortex-M ──────────────────────────────────────────────────────────── - // - // `mcmodel` is EMPTY on every row: 32-bit ARM has no `-mcmodel` axis, and - // `compile_flags` already omits the flag when this column is empty. - // - // `lldEmulation` is EMPTY on every row, and that was measured rather than - // assumed. clang has a BareMetal toolchain for arm, so these triples reach - // `ld.lld` through the driver exactly as the riscv and aarch64 rows do — - // the x86_64 row's problem does not recur here. Verified end to end: a - // freestanding thumb object links under `ld.lld` and boots under QEMU. - // - // `libdir` IS THE TRIPLE HERE, NOT `/` LIKE EVERY OTHER - // ROW, AND THE DIFFERENCE IS AN ABI SUBSTITUTION THAT NOTHING REPORTS. - // - // The column names the sub-directory a multilib C library uses, and on - // riscv `/` separates every profile because `mabi` there IS - // the float ABI — `lp64d` and `lp64` are different values. On ARM `mabi` - // names the PROCEDURE CALL STANDARD and is `aapcs` for both variants, while - // the float ABI lives in this triple's `eabi`/`eabihf` suffix. So - // `armv7e-m/aapcs` would name ONE directory for two incompatible libraries. - // - // Measured while building `xim:picolibc-arm`: under the sibling convention - // the seven profiles collapsed into five directories and - // `armv7e-m/aapcs/libc.a` came out carrying `Tag_ABI_HardFP_use` — the - // hard-float build, sitting exactly where a soft-float program would find - // it. Nothing failed at build time. - // - // Filling this does NOT give these rows a C library by default: the - // column is read only once a sysroot has been resolved, and the target - // table still binds none. The zero-libc tier stays the default and - // `[target.] sysroot = "xim:picolibc-arm@1.8.12"` is the opt-in. - // This makes the opt-in work; it does not take the opt-out away. - // - // `-mabi=aapcs`, matching the aarch64 row and for the same reason: on - // ARM `-mabi` names a procedure call standard, not a data model. - // triple march mabi mcmodel libdir extra - { "thumbv6m-none-eabi", "armv6-m", "aapcs", "", "thumbv6m-none-eabi", kThumbSoftExtra }, - { "thumbv7m-none-eabi", "armv7-m", "aapcs", "", "thumbv7m-none-eabi", kThumbSoftExtra }, - { "thumbv7em-none-eabi", "armv7e-m", "aapcs", "", "thumbv7em-none-eabi", kThumbSoftExtra }, - { "thumbv7em-none-eabihf", "armv7e-m", "aapcs", "", "thumbv7em-none-eabihf" }, - { "thumbv8m.base-none-eabi", "armv8-m.base","aapcs","", "thumbv8m.base-none-eabi", kThumbSoftExtra }, - { "thumbv8m.main-none-eabi", "armv8-m.main","aapcs","", "thumbv8m.main-none-eabi", kThumbSoftExtra }, - { "thumbv8m.main-none-eabihf","armv8-m.main","aapcs","", "thumbv8m.main-none-eabihf" }, - // ── ARMv7-A (Cortex-A, 32-bit) ────────────────────────────────────────── - // - // THE FIRST 32-BIT MACHINE WITH A MEMORY MANAGEMENT UNIT, AND THAT IS - // WHY IT IS HERE RATHER THAN BEING A SECOND SPELLING OF THE M ROWS. - // - // Every other 32-bit row in this table is M-profile: an MPU that describes - // regions by base and limit, and no page-table entry at all. A-profile has - // a real MMU with a page-table walker, so it is the first target on which - // an address-space abstraction has to answer what a 32-bit machine's entry - // looks like — short descriptors are 32 bits wide, long (LPAE) ones 64. - // That question cannot be asked on a machine with no entries. - // - // `mcmodel` and `libdir` are empty for the reasons the M rows give. - // `lldEmulation` is empty because clang's BareMetal toolchain covers arm: - // measured, the driver reaches `ld.lld` and the x86_64 row's problem does - // not recur. - // - // THE TIER IS `verified` AND THE MEASUREMENT IS NAMED. 2026-09-04 under - // `xim:qemu-arm@9.2.4-1`: both rows built an image that BOOTED on - // `-M virt -cpu cortex-a15`, printed over semihosting and reported its exit - // status. - // - // AND THE SEMIHOSTING EXIT CALL IS NOT SPELLED THE WAY M-PROFILE SPELLS - // IT. `SYS_EXIT` (0x18) on AArch32 takes the reason code in `r1` directly; - // the `{reason, code}` block every Cortex-M board here passes is - // `SYS_EXIT_EXTENDED` (0x20), which exists because a 32-bit `r1` cannot - // carry both. Passing the block to 0x18 prints correctly and then reports - // the WRONG exit status — measured: a program exiting 0 reported 1. That is - // a board fact rather than a target fact, recorded here because it is where - // the next person to write such a board will look. - // - // `libdir` IS THE TRIPLE HERE TOO, AND FOR THE SAME REASON AS THE - // M ROWS — even though no package carries an A-profile multilib yet. - // - // The column is a CONVENTION about where a sysroot puts a profile, not a - // claim that one exists: it is read only after a sysroot has been resolved, - // and an absent directory is skipped. Leaving it empty would leave the next - // A-profile payload free to choose `/` — which maps these two - // incompatible libraries onto one directory, exactly as it did for - // `armv7e-m` when `xim:picolibc-arm` was first built. - { "armv7a-none-eabi", "armv7-a", "aapcs", "", "armv7a-none-eabi", kArmSoftExtra }, - { "armv7a-none-eabihf", "armv7-a", "aapcs", "", "armv7a-none-eabihf" }, -}; +// THE ROWS ARE DATA (design 2026-11-09; SPEC-016): each bare-metal row of +// modules/toolchain-model/data/builtin.toml states its ISA profile -- +// `march`, `mabi`, `code-model`, `multilib`, `flags`, `link-emulation` -- with +// the measurements behind each value beside it. This table is that data in the +// shape the bare-metal code reads; a row with no `march` is not a bare-metal row. +inline constexpr auto kTable = [] { + constexpr auto& rows = mcpp::toolchain::builtin::kRows; + constexpr std::size_t n = [] { + std::size_t c = 0; + for (auto const& r : mcpp::toolchain::builtin::kRows) c += r.march.empty() ? 0 : 1; + return c; + }(); + std::array out{}; + std::size_t i = 0; + for (auto const& r : rows) { + if (r.march.empty()) continue; + out[i++] = Spec{ r.triple, r.march, r.mabi, r.codeModel, r.multilib, r.flags, r.linkEmulation }; + } + return out; +}(); // The single read point. Returns nullopt for anything that is not a known // bare-metal target — including hosted triples, so a caller can use this as @@ -360,7 +137,8 @@ inline std::vector compile_flags(const Spec& s, bool targetCxxRuntime = false) { std::vector out; out.emplace_back(std::string("-march=") + std::string(s.march)); - out.emplace_back(std::string("-mabi=") + std::string(s.mabi)); + if (!s.mabi.empty()) + out.emplace_back(std::string("-mabi=") + std::string(s.mabi)); if (!s.mcmodel.empty()) out.emplace_back(std::string("-mcmodel=") + std::string(s.mcmodel)); // Whatever the three columns above could not say. Emitted before diff --git a/tests/fixtures/target-rows.snapshot b/tests/fixtures/target-rows.snapshot index 8be8378ba..e7c576c00 100644 --- a/tests/fixtures/target-rows.snapshot +++ b/tests/fixtures/target-rows.snapshot @@ -75,7 +75,7 @@ row x86_64-windows-gnu descriptor-keys windows sysroot row x86_64-windows-musl - table tier=preview note=PE pin=llvm@23.1.3 sysroot= static=1 platform= + table tier=preview note=PE pin=llvm@23.1.3 sysroot= static=1 platform=mcpplibs.openkal-platform@0.24.4 str x86_64-windows-musl llvm x86_64-w64-windows-gnu llvm@15.0 x86_64-w64-windows-gnu @@ -131,6 +131,25 @@ row aarch64-macos platform-name macos descriptor-keys macosx sysroot +row aarch64-macos-musl + table tier=preview note= pin=llvm@23.1.3 sysroot= static=1 platform=mcpplibs.openkal-platform@0.24.4 + str aarch64-macos-musl + llvm arm64-apple-macos14.0 + llvm@15.0 arm64-apple-macos15.0 + format Mach-O + family unix + pe/macho/wasm 010 + apple/ios/sim/android 1000 + freestanding/openkal/own-sysroot/capability 0001 + musl/msvc/windows-gnu/x86_32 1000 + msvc-arch arm64 + nasm - + naming exe= lib=lib static=.a shared=.dylib import=0 sharedPrefix=- + application exe + android-abi arm64-v8a + platform-name macos + descriptor-keys macosx + sysroot row riscv64-linux-musl table tier=planned note= pin= sysroot= static=1 platform= str riscv64-linux-musl @@ -188,6 +207,25 @@ row x86_64-macos platform-name macos descriptor-keys macosx sysroot +row x86_64-macos-musl + table tier=preview note= pin=llvm@23.1.3 sysroot= static=1 platform=mcpplibs.openkal-platform@0.24.4 + str x86_64-macos-musl + llvm x86_64-apple-macos14.0 + llvm@15.0 x86_64-apple-macos15.0 + format Mach-O + family unix + pe/macho/wasm 010 + apple/ios/sim/android 1000 + freestanding/openkal/own-sysroot/capability 0001 + musl/msvc/windows-gnu/x86_32 1000 + msvc-arch x64 + nasm macho64 + naming exe= lib=lib static=.a shared=.dylib import=0 sharedPrefix=- + application exe + android-abi x86_64 + platform-name macos + descriptor-keys macosx + sysroot row riscv64-none-elf table tier=verified note=bare pin=llvm@23.1.3 sysroot=xim:picolibc-riscv@1.8.12 static=1 platform= str riscv64-none-elf @@ -488,6 +526,30 @@ row armv7a-none-eabihf fs.compile -march=armv7-a -mabi=aapcs -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti fs.compile+rt -march=armv7-a -mabi=aapcs -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ fs.link --target=armv7a-none-eabihf -march=armv7-a -mabi=aapcs -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld +row i686-none-elf + table tier=preview note=bare pin=llvm@23.1.3 sysroot= static=1 platform= + str i686-none-elf + llvm i686-none-elf + llvm@15.0 i686-none-elf + format ELF + family + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 1001 + musl/msvc/windows-gnu/x86_32 0001 + msvc-arch x86 + nasm - + naming exe=.host lib=hostlib static=.hosta shared=.hostso import=0 sharedPrefix=- + application exe + android-abi i686 + platform-name none + descriptor-keys none + sysroot + fs.spec march=i686 mabi= mcmodel= libdir= lld=elf_i386 + fs.compile -march=i686 -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti + fs.compile+rt -march=i686 -ffunction-sections -fdata-sections -fasynchronous-unwind-tables -nostdinc++ + fs.link --target=i686-none-elf -march=i686 -ffunction-sections -fdata-sections -ffreestanding -nostdinc++ -fno-exceptions -fno-rtti -nostdlib -nostartfiles -static -Wl,--gc-sections -fuse-ld=/lld -L/sysroot/lib -T /script.ld + fs.link-direct -m elf_i386 -Bstatic --no-dynamic-linker --gc-sections -L/sysroot/lib -T /script.ld row aarch64-linux-android table tier=verified note= pin=android-ndk@30.0.16248370 sysroot= static=0 platform= str aarch64-linux-android @@ -678,15 +740,15 @@ request aarch64-windows known 0 request x86_64-macos parsed x86_64-macos envExplicit=0 - resolved x86_64-macos completed=0 ambiguous=0 - siblings x86_64-macos - supported + resolved x86_64-macos-musl completed=1 ambiguous=0 + siblings x86_64-macos x86_64-macos-musl + supported x86_64-macos-musl known 1 request aarch64-macos parsed aarch64-macos envExplicit=0 resolved aarch64-macos completed=0 ambiguous=0 - siblings aarch64-macos - supported aarch64-macos + siblings aarch64-macos aarch64-macos-musl + supported aarch64-macos aarch64-macos-musl known 1 request aarch64-ios parsed aarch64-ios envExplicit=0 @@ -721,14 +783,14 @@ request x86_64-pc-windows-msvc request arm64-apple-darwin24.1.0 parsed aarch64-macos envExplicit=0 resolved aarch64-macos completed=0 ambiguous=0 - siblings aarch64-macos - supported aarch64-macos + siblings aarch64-macos aarch64-macos-musl + supported aarch64-macos aarch64-macos-musl known 1 request arm64-apple-macosx15.0 parsed aarch64-macos envExplicit=0 resolved aarch64-macos completed=0 ambiguous=0 - siblings aarch64-macos - supported aarch64-macos + siblings aarch64-macos aarch64-macos-musl + supported aarch64-macos aarch64-macos-musl known 1 request aarch64-unknown-linux-musl parsed aarch64-linux-musl envExplicit=1 @@ -759,7 +821,7 @@ request i686-none-elf resolved i686-none-elf completed=0 ambiguous=0 siblings supported - known 0 + known 1 request x86_64-freebsd unparseable request x86_64-linux-gnux32 @@ -775,7 +837,7 @@ set windows -> x86_64-windows-gnu | set windows-msvc -> x86_64-windows-msvc | set macos -> aarch64-macos | set openkal -> x86_64-openkal aarch64-openkal | mcpplibs.openkal-platform@0.24.4 -set none-elf -> riscv64-none-elf riscv32-none-elf aarch64-none-elf x86_64-none-elf | +set none-elf -> riscv64-none-elf riscv32-none-elf aarch64-none-elf x86_64-none-elf i686-none-elf | set none-eabihf -> thumbv7em-none-eabihf thumbv8m.main-none-eabihf armv7a-none-eabihf | set android -> aarch64-linux-android x86_64-linux-android | set ios -> aarch64-ios | From 762419cc90d88433616624b5ced358de24f85133 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 05:34:18 +0900 Subject: [PATCH 03/45] test: the unit tests are mtest's Each GoogleTest TEST is a UTest object named Suite/Case; a fixture is a case that derives from it; a message reads what holds only when the expectation failed; a test that starts copies of itself names the case to run and clears the protocol for the copy; GoogleTest's stderr capture is mcpplibs.os 0.4.1's stdio::capture. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- docs/90-build-from-source.md | 15 +- docs/zh/90-build-from-source.md | 13 +- mcpp.toml | 5 +- modules/buildmcpp/mcpp.toml | 2 +- modules/buildmcpp/tests/test_glob_inputs.cpp | 173 +- modules/buildmcpp/tests/test_protocol.cpp | 16 +- modules/graph/mcpp.toml | 2 +- modules/graph/tests/test_graph.cpp | 238 +- modules/manifest/mcpp.toml | 2 +- .../manifest/tests/test_compat_registry.cpp | 69 +- modules/manifest/tests/test_flag_words.cpp | 42 +- .../manifest/tests/test_selector_grammar.cpp | 30 +- modules/toolchain-model/mcpp.toml | 2 +- .../tests/test_platform_axis.cpp | 40 +- .../tests/test_triple_vocabulary.cpp | 60 +- modules/versioning/mcpp.toml | 2 +- .../versioning/tests/test_version_grammar.cpp | 34 +- .../versioning/tests/test_xpkg_version.cpp | 87 +- .../helpers/check_owned_schedule_lifetime.py | 2 +- .../e2e/helpers/check_shared_compiler_cap.py | 2 +- tests/unit/test_abi.cpp | 230 +- tests/unit/test_abi_tag_accel.cpp | 255 +- tests/unit/test_action_progress.cpp | 44 +- tests/unit/test_artifact_naming.cpp | 220 +- tests/unit/test_bmi_cache.cpp | 246 +- tests/unit/test_bmi_equivalent.cpp | 66 +- tests/unit/test_build_cache_record.cpp | 199 +- tests/unit/test_build_database.cpp | 137 +- tests/unit/test_build_directives.cpp | 858 ++-- tests/unit/test_build_flags.cpp | 224 +- tests/unit/test_build_legs.cpp | 34 +- tests/unit/test_build_profile.cpp | 171 +- tests/unit/test_build_progress.cpp | 464 +- tests/unit/test_build_resources.cpp | 287 +- tests/unit/test_build_stage.cpp | 234 +- tests/unit/test_c_standard_per_package.cpp | 68 +- tests/unit/test_cache_key.cpp | 365 +- tests/unit/test_capacity_jobs.cpp | 64 +- tests/unit/test_cenv.cpp | 192 +- tests/unit/test_cenv_probe.cpp | 201 +- tests/unit/test_cfg_accelerator.cpp | 82 +- tests/unit/test_cfg_accelerator_none.cpp | 38 +- tests/unit/test_cfg_os_only_platform.cpp | 116 +- tests/unit/test_cmdlimits.cpp | 91 +- tests/unit/test_coff_exports.cpp | 225 +- tests/unit/test_command_words.cpp | 37 +- tests/unit/test_compile_commands.cpp | 305 +- tests/unit/test_compiler_transaction.cpp | 447 +- tests/unit/test_config.cpp | 179 +- tests/unit/test_configure.cpp | 26 +- tests/unit/test_core_vendor_probes.cpp | 15 +- tests/unit/test_cppfly.cpp | 122 +- tests/unit/test_cxx_layer_standard.cpp | 82 +- tests/unit/test_depfile.cpp | 37 +- tests/unit/test_descriptor_keys.cpp | 42 +- tests/unit/test_detach_status.cpp | 96 +- tests/unit/test_diag.cpp | 166 +- tests/unit/test_dialect.cpp | 52 +- tests/unit/test_distribution.cpp | 590 ++- tests/unit/test_doctor_fixincludes.cpp | 47 +- tests/unit/test_doctor_runpath.cpp | 42 +- tests/unit/test_dots_screen.cpp | 134 +- tests/unit/test_dyndep.cpp | 151 +- tests/unit/test_elf_runtime.cpp | 298 +- tests/unit/test_fallback_legacy_dirs.cpp | 34 +- tests/unit/test_fetcher_install_path.cpp | 22 +- tests/unit/test_fingerprint.cpp | 105 +- tests/unit/test_freestanding.cpp | 474 +- tests/unit/test_graph_shape.cpp | 75 +- tests/unit/test_hermetic_graph_link.cpp | 43 +- tests/unit/test_home.cpp | 247 +- tests/unit/test_hostflags.cpp | 375 +- tests/unit/test_index_contract.cpp | 112 +- tests/unit/test_install_hook_env.cpp | 50 +- tests/unit/test_key_registry.cpp | 126 +- tests/unit/test_library_interface.cpp | 45 +- tests/unit/test_link_intent_spelling.cpp | 42 +- tests/unit/test_link_line.cpp | 58 +- tests/unit/test_link_model_runtime_dirs.cpp | 62 +- tests/unit/test_linkage_form.cpp | 254 +- tests/unit/test_linkmodel.cpp | 204 +- tests/unit/test_loader_contract.cpp | 173 +- tests/unit/test_luban_root.cpp | 38 +- tests/unit/test_main_detection.cpp | 62 +- tests/unit/test_mangle.cpp | 118 +- tests/unit/test_manifest.cpp | 4291 ++++++++--------- tests/unit/test_member_selection.cpp | 181 +- tests/unit/test_modgraph.cpp | 784 ++- tests/unit/test_module_address.cpp | 120 +- tests/unit/test_msvc_coroutines.cpp | 144 +- tests/unit/test_network.cpp | 157 +- tests/unit/test_ninja_backend.cpp | 1306 +++-- tests/unit/test_object_address.cpp | 116 +- tests/unit/test_p1689.cpp | 92 +- tests/unit/test_pack_abi_tag.cpp | 160 +- tests/unit/test_pack_archive_remove.cpp | 44 +- tests/unit/test_pack_binfmt.cpp | 251 +- tests/unit/test_pack_closure.cpp | 173 +- tests/unit/test_pack_distribution.cpp | 624 ++- tests/unit/test_pack_interface.cpp | 137 +- tests/unit/test_pack_members.cpp | 95 +- tests/unit/test_pack_modes.cpp | 83 +- tests/unit/test_pack_polyglot.cpp | 62 +- tests/unit/test_pack_relocate.cpp | 154 +- tests/unit/test_pack_route.cpp | 40 +- tests/unit/test_pack_stage_tree.cpp | 176 +- tests/unit/test_patch.cpp | 104 +- tests/unit/test_pe_exports.cpp | 143 +- tests/unit/test_platform.cpp | 415 +- tests/unit/test_platform_apple_sdk.cpp | 87 +- tests/unit/test_platform_env.cpp | 49 +- tests/unit/test_platform_fs.cpp | 92 +- tests/unit/test_platform_terminal.cpp | 38 +- tests/unit/test_pm_bare_name_window.cpp | 20 +- tests/unit/test_pm_compat.cpp | 212 +- tests/unit/test_pm_index_refresh.cpp | 126 +- tests/unit/test_pm_index_route.cpp | 131 +- tests/unit/test_pm_lock_io.cpp | 229 +- tests/unit/test_pm_package_fetcher.cpp | 198 +- tests/unit/test_post_install.cpp | 70 +- tests/unit/test_predefines.cpp | 84 +- tests/unit/test_prepare_helpers.cpp | 63 +- tests/unit/test_process_run_exec.cpp | 318 +- tests/unit/test_process_seal_stdin.cpp | 26 +- tests/unit/test_progress_render.cpp | 87 +- tests/unit/test_provisions.cpp | 303 +- tests/unit/test_run_concurrent_children.cpp | 16 +- tests/unit/test_runner_lookup.cpp | 146 +- tests/unit/test_runtime_binding_fallback.cpp | 54 +- tests/unit/test_runtime_contract.cpp | 247 +- tests/unit/test_runtime_placement.cpp | 118 +- tests/unit/test_runtime_search.cpp | 102 +- tests/unit/test_runtime_selection.cpp | 271 +- tests/unit/test_scaffold.cpp | 275 +- tests/unit/test_schedule_policy.cpp | 154 +- tests/unit/test_search_versions.cpp | 26 +- tests/unit/test_select_sources.cpp | 22 +- tests/unit/test_short_name_scan.cpp | 34 +- tests/unit/test_source_digest.cpp | 32 +- tests/unit/test_source_kind.cpp | 261 +- tests/unit/test_sources.cpp | 286 +- tests/unit/test_static_placement.cpp | 94 +- tests/unit/test_subos_info.cpp | 230 +- tests/unit/test_subsystem_contracts.cpp | 32 +- tests/unit/test_symbol_provision.cpp | 191 +- tests/unit/test_sysroot_ownership.cpp | 70 +- tests/unit/test_target_base.cpp | 243 +- tests/unit/test_target_row_spelling.cpp | 22 +- tests/unit/test_target_rows_snapshot.cpp | 17 +- tests/unit/test_target_runtime_frameworks.cpp | 16 +- tests/unit/test_target_scalar_keys.cpp | 52 +- tests/unit/test_target_set.cpp | 186 +- tests/unit/test_target_static.cpp | 64 +- tests/unit/test_target_xlings_axis.cpp | 112 +- tests/unit/test_targetside.cpp | 504 +- tests/unit/test_test_options.cpp | 62 +- tests/unit/test_test_protocol.cpp | 96 +- tests/unit/test_test_targets.cpp | 136 +- tests/unit/test_toml.cpp | 229 +- tests/unit/test_toml_serialize.cpp | 52 +- tests/unit/test_tool_store.cpp | 50 +- tests/unit/test_toolchain_compat.cpp | 146 +- tests/unit/test_toolchain_descriptor.cpp | 159 +- tests/unit/test_toolchain_detect.cpp | 78 +- tests/unit/test_toolchain_dialect.cpp | 273 +- tests/unit/test_toolchain_lifecycle.cpp | 84 +- tests/unit/test_toolchain_msvc.cpp | 783 ++- tests/unit/test_toolchain_probe.cpp | 46 +- tests/unit/test_toolchain_registry.cpp | 435 +- tests/unit/test_toolchain_stdmod.cpp | 74 +- tests/unit/test_toolchain_triple.cpp | 1001 ++-- tests/unit/test_ui_closing_notices.cpp | 39 +- tests/unit/test_ui_streams.cpp | 92 +- tests/unit/test_version_floor.cpp | 86 +- tests/unit/test_version_req.cpp | 484 +- tests/unit/test_windows_command_line.cpp | 86 +- tests/unit/test_windows_defaults.cpp | 114 +- tests/unit/test_wire.cpp | 212 +- tests/unit/test_workspace_inheritance.cpp | 279 +- tests/unit/test_workspace_plan.cpp | 120 +- tests/unit/test_xlings.cpp | 362 +- tests/unit/test_xlings_address_set.cpp | 140 +- tests/unit/test_xlings_version_pin.cpp | 153 +- tests/unit/test_xpkg_emit.cpp | 123 +- tests/unit/test_xpkg_host_tools.cpp | 64 +- tests/unit/test_xpkg_versions_merge.cpp | 107 +- 186 files changed, 16806 insertions(+), 16755 deletions(-) diff --git a/docs/90-build-from-source.md b/docs/90-build-from-source.md index 7c2f966b8..d840b4cf1 100644 --- a/docs/90-build-from-source.md +++ b/docs/90-build-from-source.md @@ -81,19 +81,22 @@ src/ └── scaffold/ `mcpp new` and templates tests/ -├── unit/ 156 C++ tests, discovered by `mcpp test` -└── e2e/ 649 shell scripts against a real binary +├── unit/ 166 mtest programs, discovered by `mcpp test` +└── e2e/ 711 shell scripts against a real binary ``` ## Test Organization Two layers, and they answer different questions. -**Unit tests** (`tests/unit/`, 156 files) are C++ programs `mcpp test` -discovers. They exercise a module's contract with no binary and no filesystem -state. +**Unit tests** (`tests/unit/`, 166 files, and each member's `modules//tests/`) +are C++ programs `mcpp test` discovers. They exercise a module's contract with no +binary and no filesystem state. Each is a file of [mtest](https://github.com/mcpp-community/mtest) +cases: an object `UTest name{"Suite/Case", [] { ... }};` is one case, with no `main` +(`mcpp test --guide`). `mcpp test --case 'TargetSelect/*'` runs the cases a pattern +names; `mcpp test -p ` runs a member's. -**End-to-end tests** (`tests/e2e/NN_.sh`, 649 files) run a real `mcpp` +**End-to-end tests** (`tests/e2e/NN_.sh`, 711 files) run a real `mcpp` binary against a real project. `run_all.sh` is the CI entry point. `mcpp test` does **not** run them. diff --git a/docs/zh/90-build-from-source.md b/docs/zh/90-build-from-source.md index ba766fbdf..b5f27ef50 100644 --- a/docs/zh/90-build-from-source.md +++ b/docs/zh/90-build-from-source.md @@ -83,18 +83,21 @@ src/ └── scaffold/ `mcpp new` and templates tests/ -├── unit/ 156 C++ tests, discovered by `mcpp test` -└── e2e/ 649 shell scripts against a real binary +├── unit/ 166 mtest programs, discovered by `mcpp test` +└── e2e/ 711 shell scripts against a real binary ``` ## 测试组织 两层,回答的是不同的问题。 -**单元测试**(`tests/unit/`,156 个文件)是 `mcpp test` 发现的 C++ -程序。它们检验一个模块的契约,不需要二进制、也不需要文件系统状态。 +**单元测试**(`tests/unit/`,166 个文件,以及各成员的 `modules//tests/`)是 `mcpp test` +发现的 C++ 程序。它们检验一个模块的契约,不需要二进制、也不需要文件系统状态。每个文件由 +[mtest](https://github.com/mcpp-community/mtest) 的用例组成:一个对象 `UTest name{"Suite/Case", [] { ... }};` +即一个用例,文件不写 `main`(`mcpp test --guide`)。`mcpp test --case 'TargetSelect/*'` 运行模式所指的用例; +`mcpp test -p <成员>` 运行一个成员的用例。 -**端到端测试**(`tests/e2e/NN_.sh`,649 个文件)拿真实的 `mcpp` +**端到端测试**(`tests/e2e/NN_.sh`,711 个文件)拿真实的 `mcpp` 二进制跑真实工程。`run_all.sh` 是 CI 的入口。`mcpp test` **不**运行它们。 ```bash diff --git a/mcpp.toml b/mcpp.toml index e505fe556..2058f96ca 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -111,7 +111,7 @@ mcpplibs.mwrap.xz = "5.8.3" # The operating system is mcpplibs.os's (design 2026-10-26 §25): mcpp keeps no # layer of its own. -mcpplibs.os = "0.4.0" +mcpplibs.os = "0.4.1" # `modules/` holds what gets linked into this binary; `src/` is what has not # been separated yet, and shrinking it is the direction. Each entry is an @@ -163,9 +163,8 @@ members = [ ] # `mcpp build` ignores [dev-dependencies]; only `mcpp test` resolves them. -# Namespace omission means exactly mcpplibs, so the compat wrapper is explicit. [dev-dependencies] -compat.gtest = "1.15.2" +mcpplibs.mtest = "0.4.1" # Everything else uses M5.0 conventions: # - target = mcpp (kind=bin inferred from src/main.cpp) diff --git a/modules/buildmcpp/mcpp.toml b/modules/buildmcpp/mcpp.toml index 30dad4d63..a9af1116b 100644 --- a/modules/buildmcpp/mcpp.toml +++ b/modules/buildmcpp/mcpp.toml @@ -41,4 +41,4 @@ mcpp.source-kind = { path = "../source-kind" } mcpp.toolchain-model = { path = "../toolchain-model" } [dev-dependencies] -compat.gtest = "1.15.2" +mcpplibs.mtest = "0.4.1" diff --git a/modules/buildmcpp/tests/test_glob_inputs.cpp b/modules/buildmcpp/tests/test_glob_inputs.cpp index 312401a4b..01f74b811 100644 --- a/modules/buildmcpp/tests/test_glob_inputs.cpp +++ b/modules/buildmcpp/tests/test_glob_inputs.cpp @@ -1,25 +1,27 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.directives; import mcpp.toolchain.fingerprint; +using namespace mcpplibs::mtest; + namespace dirs = mcpp::build::directives; namespace { -struct GlobInputs : testing::Test { +struct GlobInputs { std::filesystem::path tree; std::filesystem::path root; - void SetUp() override { + void SetUp() { tree = std::filesystem::temp_directory_path() / "mcpp_parent_glob_inputs"; std::filesystem::remove_all(tree); root = tree / "workspace" / "app"; std::filesystem::create_directories(root); } - void TearDown() override { + void TearDown() { std::error_code ec; std::filesystem::remove_all(tree, ec); } @@ -38,105 +40,168 @@ struct GlobInputs : testing::Test { } // namespace -TEST_F(GlobInputs, ParentDirectoryMembershipInvalidatesTheFingerprint) { +UTest parent_directory_membership_invalidates_the_fingerprint{"GlobInputs/ParentDirectoryMembershipInvalidatesTheFingerprint", [] { + struct Case : GlobInputs { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { write("../inputs/a.in"); const auto one = fingerprint("../inputs/**/*.in"); - EXPECT_NE(one, mcpp::toolchain::hash_string("")); + expect(ne(one, mcpp::toolchain::hash_string(""))); write("../inputs/nested/b.in"); - EXPECT_NE(fingerprint("../inputs/**/*.in"), one); + expect(ne(fingerprint("../inputs/**/*.in"), one)); std::filesystem::remove(root / "../inputs/nested/b.in"); - EXPECT_EQ(fingerprint("../inputs/**/*.in"), one); + expect(eq(fingerprint("../inputs/**/*.in"), one)); write("../inputs/a.in", "different contents"); write("../inputs/ignored.txt"); write("../unrelated/c.in"); - EXPECT_EQ(fingerprint("../inputs/**/*.in"), one); + expect(eq(fingerprint("../inputs/**/*.in"), one)); } - -TEST_F(GlobInputs, MultipleParentLevelsAndExactPathsAreWatched) { + } fixture; + fixture.body(); +}}; + +UTest multiple_parent_levels_and_exact_paths_are_watched{"GlobInputs/MultipleParentLevelsAndExactPathsAreWatched", [] { + struct Case : GlobInputs { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { const auto before = fingerprint("../../inputs/*.in"); write("../../inputs/a.in"); - EXPECT_NE(fingerprint("../../inputs/*.in"), before); - EXPECT_NE(fingerprint("../../inputs/a.in"), before); + expect(ne(fingerprint("../../inputs/*.in"), before)); + expect(ne(fingerprint("../../inputs/a.in"), before)); std::filesystem::remove(root / "../../inputs/a.in"); - EXPECT_EQ(fingerprint("../../inputs/*.in"), before); + expect(eq(fingerprint("../../inputs/*.in"), before)); } - -TEST_F(GlobInputs, MissingLiteralDirectoryDoesNotHideLaterFiles) { + } fixture; + fixture.body(); +}}; + +UTest missing_literal_directory_does_not_hide_later_files{"GlobInputs/MissingLiteralDirectoryDoesNotHideLaterFiles", [] { + struct Case : GlobInputs { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { const auto before = fingerprint("../missing/**/*.in"); write("../missing/ignored.txt"); - EXPECT_EQ(fingerprint("../missing/**/*.in"), before); + expect(eq(fingerprint("../missing/**/*.in"), before)); write("../missing/nested/a.in"); - EXPECT_NE(fingerprint("../missing/**/*.in"), before); + expect(ne(fingerprint("../missing/**/*.in"), before)); } - -TEST_F(GlobInputs, LiteralPrefixDoesNotBypassExcludedDirectories) { + } fixture; + fixture.body(); +}}; + +UTest literal_prefix_does_not_bypass_excluded_directories{"GlobInputs/LiteralPrefixDoesNotBypassExcludedDirectories", [] { + struct Case : GlobInputs { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { const auto before = fingerprint("**"); write("target/generated.in"); write(".git/index.in"); write("../inputs/target/generated.in"); write("../inputs/.git/index.in"); - EXPECT_EQ(fingerprint("**"), before); + expect(eq(fingerprint("**"), before)); for (const auto pattern : {"target/**", ".git/**", "../inputs/target/**", "../inputs/.git/**", "../inputs/**"}) - EXPECT_EQ(fingerprint(pattern), mcpp::toolchain::hash_string("")) << pattern; + expect(eq(fingerprint(pattern), mcpp::toolchain::hash_string(""))) << pattern; } - -TEST_F(GlobInputs, ParentPatternsRetainPathSetSemantics) { + } fixture; + fixture.body(); +}}; + +UTest parent_patterns_retain_path_set_semantics{"GlobInputs/ParentPatternsRetainPathSetSemantics", [] { + struct Case : GlobInputs { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { write("../inputs/a.in"); const auto before = fingerprint("../inputs/**"); write("../inputs/a.in", "longer content"); std::filesystem::last_write_time(root / "../inputs/a.in", std::filesystem::file_time_type::clock::now() + std::chrono::hours(1)); - EXPECT_EQ(fingerprint("../inputs/**"), before); + expect(eq(fingerprint("../inputs/**"), before)); write("../inputs/nested/b.in"); - EXPECT_NE(fingerprint("../inputs/**"), before); + expect(ne(fingerprint("../inputs/**"), before)); } - -TEST_F(GlobInputs, DirectorySymlinksAreFollowedAsTheSourceScanFollowsThem) { + } fixture; + fixture.body(); +}}; + +UTest directory_symlinks_are_followed_as_the_source_scan_follows_them{"GlobInputs/DirectorySymlinksAreFollowedAsTheSourceScanFollowsThem", [] { + struct Case : GlobInputs { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { write("../real/nested/a.in"); std::error_code ec; std::filesystem::create_directory_symlink(tree / "workspace" / "real", root / "../link", ec); - if (ec) GTEST_SKIP() << "Directory symlinks are unavailable: " << ec.message(); + if (ec) skip([&] { std::ostringstream text; text << "Directory symlinks are unavailable: " << ec.message(); return text.str(); }()); const auto before = fingerprint("../link/**/*.in"); - EXPECT_NE(before, mcpp::toolchain::hash_string("")); - EXPECT_NE(fingerprint("../link/nested/*.in"), mcpp::toolchain::hash_string("")); + expect(ne(before, mcpp::toolchain::hash_string(""))); + expect(ne(fingerprint("../link/nested/*.in"), mcpp::toolchain::hash_string(""))); write("../real/nested/b.in"); - EXPECT_NE(fingerprint("../link/**/*.in"), before); + expect(ne(fingerprint("../link/**/*.in"), before)); } - -TEST_F(GlobInputs, ALinkCycleEndsTheWalk) { + } fixture; + fixture.body(); +}}; + +UTest a_link_cycle_ends_the_walk{"GlobInputs/ALinkCycleEndsTheWalk", [] { + struct Case : GlobInputs { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { write("../loop/a.in"); std::error_code ec; std::filesystem::create_directory_symlink(tree / "workspace" / "loop", root / "../loop/again", ec); - if (ec) GTEST_SKIP() << "Directory symlinks are unavailable: " << ec.message(); - EXPECT_NE(fingerprint("../loop/**/*.in"), mcpp::toolchain::hash_string("")); + if (ec) skip([&] { std::ostringstream text; text << "Directory symlinks are unavailable: " << ec.message(); return text.str(); }()); + expect(ne(fingerprint("../loop/**/*.in"), mcpp::toolchain::hash_string(""))); } - -TEST_F(GlobInputs, AbsolutePatternsMatchAbsolutePaths) { + } fixture; + fixture.body(); +}}; + +UTest absolute_patterns_match_absolute_paths{"GlobInputs/AbsolutePatternsMatchAbsolutePaths", [] { + struct Case : GlobInputs { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { write("../inputs/a.in"); const auto dir = (tree / "workspace" / "inputs").lexically_normal().generic_string(); const auto before = fingerprint(dir + "/**/*.in"); - EXPECT_NE(before, mcpp::toolchain::hash_string("")); + expect(ne(before, mcpp::toolchain::hash_string(""))); write("../inputs/nested/b.in"); - EXPECT_NE(fingerprint(dir + "/**/*.in"), before); + expect(ne(fingerprint(dir + "/**/*.in"), before)); } - -TEST(GlobInputBoundary, PatternsThatLeaveThePackageAreRecognised) { - EXPECT_FALSE(dirs::glob_leaves_package("proto/**/*.proto")); - EXPECT_FALSE(dirs::glob_leaves_package("a/../b/*.in")); - EXPECT_TRUE(dirs::glob_leaves_package("../inputs/**/*.in")); - EXPECT_TRUE(dirs::glob_leaves_package("a/../../inputs/*.in")); - EXPECT_TRUE(dirs::glob_leaves_package("/usr/share/data/*.in")); - EXPECT_TRUE(dirs::glob_leaves_package("C:/data/*.in")); -} - -TEST_F(GlobInputs, AnUnwatchablePatternSaysWhy) { - EXPECT_FALSE(dirs::glob_unwatchable(root, "proto/**", "target").has_value()); - EXPECT_FALSE(dirs::glob_unwatchable(root, "../inputs/**", "target").has_value()); + } fixture; + fixture.body(); +}}; + +UTest patterns_that_leave_the_package_are_recognised{"GlobInputBoundary/PatternsThatLeaveThePackageAreRecognised", [] { + expect(!dirs::glob_leaves_package("proto/**/*.proto")); + expect(!dirs::glob_leaves_package("a/../b/*.in")); + expect(bool(dirs::glob_leaves_package("../inputs/**/*.in"))); + expect(bool(dirs::glob_leaves_package("a/../../inputs/*.in"))); + expect(bool(dirs::glob_leaves_package("/usr/share/data/*.in"))); + expect(bool(dirs::glob_leaves_package("C:/data/*.in"))); +}}; + +UTest an_unwatchable_pattern_says_why{"GlobInputs/AnUnwatchablePatternSaysWhy", [] { + struct Case : GlobInputs { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { + expect(!(dirs::glob_unwatchable(root, "proto/**", "target").has_value())); + expect(!(dirs::glob_unwatchable(root, "../inputs/**", "target").has_value())); for (const auto pattern : {"target/**", ".git/HEAD", "../inputs/.mcpp/*", "/*.in"}) - EXPECT_TRUE(dirs::glob_unwatchable(root, pattern, "target").has_value()) << pattern; + expect(dirs::glob_unwatchable(root, pattern, "target").has_value()) << pattern; } + } fixture; + fixture.body(); +}}; diff --git a/modules/buildmcpp/tests/test_protocol.cpp b/modules/buildmcpp/tests/test_protocol.cpp index 090096182..1f3d653f7 100644 --- a/modules/buildmcpp/tests/test_protocol.cpp +++ b/modules/buildmcpp/tests/test_protocol.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.program_protocol; +using namespace mcpplibs::mtest; + // A SUBSYSTEM-LEVEL test: it knows only this package, and it is the only place // the protocol's own invariants can be stated without an engine around them. // @@ -12,13 +14,13 @@ import mcpp.build.program_protocol; namespace pp = mcpp::build::program_protocol; -TEST(ProgramProtocol, TheVersionIsPositiveAndMonotone) { +UTest the_version_is_positive_and_monotone{"ProgramProtocol/TheVersionIsPositiveAndMonotone", [] { // A protocol version of zero would make "declared nothing" and "declared // version zero" the same observation, which is the distinction the whole // unknown-directive rule rests on. - EXPECT_GT(pp::kProtocolVersion, 0); -} + expect(gt(pp::kProtocolVersion, 0)); +}}; -TEST(ProgramProtocol, TheCacheEpochIsPositive) { - EXPECT_GT(pp::kCacheEpoch, 0); -} +UTest the_cache_epoch_is_positive{"ProgramProtocol/TheCacheEpochIsPositive", [] { + expect(gt(pp::kCacheEpoch, 0)); +}}; diff --git a/modules/graph/mcpp.toml b/modules/graph/mcpp.toml index 5d25978dc..9395551a5 100644 --- a/modules/graph/mcpp.toml +++ b/modules/graph/mcpp.toml @@ -17,4 +17,4 @@ description = "Algorithms over index-addressed directed graphs: topological orde license = "Apache-2.0" [dev-dependencies] -compat.gtest = "1.15.2" +mcpplibs.mtest = "0.4.1" diff --git a/modules/graph/tests/test_graph.cpp b/modules/graph/tests/test_graph.cpp index fabae372f..02fb9133e 100644 --- a/modules/graph/tests/test_graph.cpp +++ b/modules/graph/tests/test_graph.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.graph; +using namespace mcpplibs::mtest; + // SUBSYSTEM-LEVEL: this package answers "what order / what depth / what does // this reach" for an index-addressed directed graph, and nothing else. The // migrated callers (src/modgraph/graph.cppm, src/build/prepare/features.cpp, @@ -21,28 +23,28 @@ g::AdjacencyList chain(std::size_t n) { } } -TEST(TopologicalOrder, EmptyGraphOrdersNothing) { +UTest empty_graph_orders_nothing{"TopologicalOrder/EmptyGraphOrdersNothing", [] { auto r = g::topological_order({}); - ASSERT_TRUE(r.has_value()); - EXPECT_TRUE(r->empty()); -} + expect(fatal(r.has_value())); + expect(r->empty()); +}}; -TEST(TopologicalOrder, SingleNodeNoDeps) { +UTest single_node_no_deps{"TopologicalOrder/SingleNodeNoDeps", [] { auto r = g::topological_order({{}}); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(*r, (std::vector{0})); -} + expect(fatal(r.has_value())); + expect(eq(*r, (std::vector{0}))); +}}; -TEST(TopologicalOrder, UnconstrainedNodesComeOutInInputIndexOrder) { +UTest unconstrained_nodes_come_out_in_input_index_order{"TopologicalOrder/UnconstrainedNodesComeOutInInputIndexOrder", [] { // No edges at all: every node is ready from the start, and ties are // broken by index, so the order is the identity permutation. g::AdjacencyList deps(5); auto r = g::topological_order(deps); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(*r, (std::vector{0, 1, 2, 3, 4})); -} + expect(fatal(r.has_value())); + expect(eq(*r, (std::vector{0, 1, 2, 3, 4}))); +}}; -TEST(TopologicalOrder, TiesAreBrokenByInputIndexEvenAgainstDeclarationOrder) { +UTest ties_are_broken_by_input_index_even_against_declaration_order{"TopologicalOrder/TiesAreBrokenByInputIndexEvenAgainstDeclarationOrder", [] { // Node 2 depends on nothing but is declared last among the roots; nodes // 0 and 1 depend on nothing either. All three become ready at once, so // the tie-break (lowest index first) — not the order dependents were @@ -50,22 +52,22 @@ TEST(TopologicalOrder, TiesAreBrokenByInputIndexEvenAgainstDeclarationOrder) { g::AdjacencyList deps(4); deps[3] = {2, 0, 1}; // declared in a scrambled order on purpose auto r = g::topological_order(deps); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(*r, (std::vector{0, 1, 2, 3})); -} + expect(fatal(r.has_value())); + expect(eq(*r, (std::vector{0, 1, 2, 3}))); +}}; -TEST(TopologicalOrder, Diamond) { +UTest diamond{"TopologicalOrder/Diamond", [] { // 3 depends on {1, 2}; 1 and 2 both depend on {0}. g::AdjacencyList deps(4); deps[1] = {0}; deps[2] = {0}; deps[3] = {1, 2}; auto r = g::topological_order(deps); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(*r, (std::vector{0, 1, 2, 3})); -} + expect(fatal(r.has_value())); + expect(eq(*r, (std::vector{0, 1, 2, 3}))); +}}; -TEST(TopologicalOrder, DisconnectedComponentsOrderByIndexNotByComponent) { +UTest disconnected_components_order_by_index_not_by_component{"TopologicalOrder/DisconnectedComponentsOrderByIndexNotByComponent", [] { // Two unrelated chains, 0 -> 2 and 1 -> 3 (node 2 depends on node 0, // node 3 depends on node 1). Both roots are ready at once, so the // tie-break processes 0 then 1 before either child — the two @@ -75,26 +77,26 @@ TEST(TopologicalOrder, DisconnectedComponentsOrderByIndexNotByComponent) { deps[2] = {0}; deps[3] = {1}; auto r = g::topological_order(deps); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(*r, (std::vector{0, 1, 2, 3})); -} + expect(fatal(r.has_value())); + expect(eq(*r, (std::vector{0, 1, 2, 3}))); +}}; -TEST(TopologicalOrder, LongChainIsTheIdentityPermutation) { +UTest long_chain_is_the_identity_permutation{"TopologicalOrder/LongChainIsTheIdentityPermutation", [] { auto deps = chain(6); auto r = g::topological_order(deps); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(*r, (std::vector{0, 1, 2, 3, 4, 5})); -} + expect(fatal(r.has_value())); + expect(eq(*r, (std::vector{0, 1, 2, 3, 4, 5}))); +}}; -TEST(TopologicalOrder, SelfLoopIsACycleOfLengthOne) { +UTest self_loop_is_a_cycle_of_length_one{"TopologicalOrder/SelfLoopIsACycleOfLengthOne", [] { g::AdjacencyList deps(1); deps[0] = {0}; auto r = g::topological_order(deps); - ASSERT_FALSE(r.has_value()); - EXPECT_EQ(r.error().cycle, (std::vector{0, 0})); -} + expect(fatal(!r.has_value())); + expect(eq(r.error().cycle, (std::vector{0, 0}))); +}}; -TEST(TopologicalOrder, LongerCycleReportsTheExactPath) { +UTest longer_cycle_reports_the_exact_path{"TopologicalOrder/LongerCycleReportsTheExactPath", [] { // a(0) -> b(1) -> c(2) -> a(0), plus an unrelated node 3 that depends on // nothing and must not appear in the ring. g::AdjacencyList deps(4); @@ -102,50 +104,50 @@ TEST(TopologicalOrder, LongerCycleReportsTheExactPath) { deps[1] = {2}; // b depends on c deps[2] = {0}; // c depends on a auto r = g::topological_order(deps); - ASSERT_FALSE(r.has_value()); + expect(fatal(!r.has_value())); // The path starts wherever the DFS first re-enters the ring; what is // pinned down is that it IS the ring a -> b -> c -> a, walked in one // direction, closed at both ends by the same node. const auto& cycle = r.error().cycle; - ASSERT_GE(cycle.size(), 2u); - EXPECT_EQ(cycle.front(), cycle.back()); + expect(fatal(ge(cycle.size(), 2u))); + expect(eq(cycle.front(), cycle.back())); for (std::size_t i = 0; i + 1 < cycle.size(); ++i) { auto u = cycle[i], v = cycle[i + 1]; - EXPECT_TRUE(std::ranges::find(deps[u], v) != deps[u].end()) + expect(std::ranges::find(deps[u], v) != deps[u].end()) << u << " does not declare a dependency on " << v; } // Exactly the three ring members, not the unrelated fourth node. std::set members(cycle.begin(), cycle.end()); - EXPECT_EQ(members, (std::set{0, 1, 2})); -} + expect(eq(members, (std::set{0, 1, 2}))); +}}; -TEST(TopologicalOrder, CycleAmongOtherwiseOrderableNodes) { +UTest cycle_among_otherwise_orderable_nodes{"TopologicalOrder/CycleAmongOtherwiseOrderableNodes", [] { // 0 is a normal leaf; 1 and 2 cycle on each other; 3 depends on 1. g::AdjacencyList deps(4); deps[1] = {2}; deps[2] = {1}; deps[3] = {1}; auto r = g::topological_order(deps); - ASSERT_FALSE(r.has_value()); + expect(fatal(!r.has_value())); std::set members(r.error().cycle.begin(), r.error().cycle.end()); - EXPECT_EQ(members, (std::set{1, 2})); -} + expect(eq(members, (std::set{1, 2}))); +}}; -TEST(Levels, LeavesAreLevelZero) { +UTest leaves_are_level_zero{"Levels/LeavesAreLevelZero", [] { g::AdjacencyList deps(3); auto r = g::levels(deps); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(*r, (std::vector{0, 0, 0})); -} + expect(fatal(r.has_value())); + expect(eq(*r, (std::vector{0, 0, 0}))); +}}; -TEST(Levels, ChainCountsUp) { +UTest chain_counts_up{"Levels/ChainCountsUp", [] { auto deps = chain(4); auto r = g::levels(deps); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(*r, (std::vector{0, 1, 2, 3})); -} + expect(fatal(r.has_value())); + expect(eq(*r, (std::vector{0, 1, 2, 3}))); +}}; -TEST(Levels, DiamondTakesTheLongerArm) { +UTest diamond_takes_the_longer_arm{"Levels/DiamondTakesTheLongerArm", [] { // 4 depends on {1, 3}; 1 depends on {0}; 3 depends on {2}, {2} depends // on {0}. The two arms from 0 to 4 have different lengths (2 and 3), and // the level is the LONGER one. @@ -155,79 +157,79 @@ TEST(Levels, DiamondTakesTheLongerArm) { deps[3] = {2}; deps[4] = {1, 3}; auto r = g::levels(deps); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ((*r)[0], 0u); - EXPECT_EQ((*r)[1], 1u); - EXPECT_EQ((*r)[2], 1u); - EXPECT_EQ((*r)[3], 2u); - EXPECT_EQ((*r)[4], 3u); -} - -TEST(Levels, DisconnectedNodesAreIndependent) { + expect(fatal(r.has_value())); + expect(eq((*r)[0], 0u)); + expect(eq((*r)[1], 1u)); + expect(eq((*r)[2], 1u)); + expect(eq((*r)[3], 2u)); + expect(eq((*r)[4], 3u)); +}}; + +UTest disconnected_nodes_are_independent{"Levels/DisconnectedNodesAreIndependent", [] { g::AdjacencyList deps(3); deps[2] = {1}; // node 0 is isolated and must not affect node 2's level. auto r = g::levels(deps); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ((*r)[0], 0u); - EXPECT_EQ((*r)[1], 0u); - EXPECT_EQ((*r)[2], 1u); -} + expect(fatal(r.has_value())); + expect(eq((*r)[0], 0u)); + expect(eq((*r)[1], 0u)); + expect(eq((*r)[2], 1u)); +}}; -TEST(Levels, SameCycleErrorAsTopologicalOrder) { +UTest same_cycle_error_as_topological_order{"Levels/SameCycleErrorAsTopologicalOrder", [] { g::AdjacencyList deps(2); deps[0] = {1}; deps[1] = {0}; auto r = g::levels(deps); - ASSERT_FALSE(r.has_value()); + expect(fatal(!r.has_value())); std::set members(r.error().cycle.begin(), r.error().cycle.end()); - EXPECT_EQ(members, (std::set{0, 1})); -} + expect(eq(members, (std::set{0, 1}))); +}}; -TEST(Closure, DirectNeighboursAreJustAdjacency) { +UTest direct_neighbours_are_just_adjacency{"Closure/DirectNeighboursAreJustAdjacency", [] { g::AdjacencyList deps(3); deps[0] = {1, 2}; - EXPECT_EQ(deps[0], (std::vector{1, 2})); -} + expect(eq(deps[0], (std::vector{1, 2}))); +}}; -TEST(Closure, ExcludesRootsByDefault) { +UTest excludes_roots_by_default{"Closure/ExcludesRootsByDefault", [] { // 0 -> 1 -> 2 -> 3 (a chain of dependencies). g::AdjacencyList deps(4); deps[0] = {1}; deps[1] = {2}; deps[2] = {3}; auto c = g::closure(deps, std::array{std::size_t{0}}); - EXPECT_EQ(c, (std::vector{1, 2, 3})); -} + expect(eq(c, (std::vector{1, 2, 3}))); +}}; -TEST(Closure, IncludeRootsAddsThemBack) { +UTest include_roots_adds_them_back{"Closure/IncludeRootsAddsThemBack", [] { g::AdjacencyList deps(4); deps[0] = {1}; deps[1] = {2}; deps[2] = {3}; auto c = g::closure(deps, std::array{std::size_t{0}}, /*include_roots=*/true); - EXPECT_EQ(c, (std::vector{0, 1, 2, 3})); -} + expect(eq(c, (std::vector{0, 1, 2, 3}))); +}}; -TEST(Closure, MultipleRootsUnionAndDeduplicate) { +UTest multiple_roots_union_and_deduplicate{"Closure/MultipleRootsUnionAndDeduplicate", [] { // 0 -> 2, 1 -> 2 -> 3: both roots reach 2, so it appears once. g::AdjacencyList deps(4); deps[0] = {2}; deps[1] = {2}; deps[2] = {3}; auto c = g::closure(deps, std::array{std::size_t{0}, std::size_t{1}}); - EXPECT_EQ(c, (std::vector{2, 3})); -} + expect(eq(c, (std::vector{2, 3}))); +}}; -TEST(Closure, DisconnectedNodeIsNeverReached) { +UTest disconnected_node_is_never_reached{"Closure/DisconnectedNodeIsNeverReached", [] { g::AdjacencyList deps(3); deps[0] = {1}; // node 2 is isolated: no root can reach it. auto c = g::closure(deps, std::array{std::size_t{0}}); - EXPECT_EQ(c, (std::vector{1})); -} + expect(eq(c, (std::vector{1}))); +}}; -TEST(Closure, TolerantOfACycleReachableFromARoot) { +UTest tolerant_of_a_cycle_reachable_from_a_root{"Closure/TolerantOfACycleReachableFromARoot", [] { // 0 -> 1 -> 2 -> 1 (a cycle downstream of the root). Closure does not // reject it; it just returns the reachable set once. g::AdjacencyList deps(3); @@ -235,14 +237,14 @@ TEST(Closure, TolerantOfACycleReachableFromARoot) { deps[1] = {2}; deps[2] = {1}; auto c = g::closure(deps, std::array{std::size_t{0}}); - EXPECT_EQ(c, (std::vector{1, 2})); -} + expect(eq(c, (std::vector{1, 2}))); +}}; -TEST(Closure, RootWithNoEdgesHasAnEmptyClosure) { +UTest root_with_no_edges_has_an_empty_closure{"Closure/RootWithNoEdgesHasAnEmptyClosure", [] { g::AdjacencyList deps(2); auto c = g::closure(deps, std::array{std::size_t{0}}); - EXPECT_TRUE(c.empty()); -} + expect(c.empty()); +}}; // ── stack_topological_order: the module graph's unit order ────────────────── // @@ -266,31 +268,31 @@ std::vector reference_stack_order( } } // namespace -TEST(StackTopologicalOrder, IsTheModuleGraphsHistoricalOrder) { +UTest is_the_module_graphs_historical_order{"StackTopologicalOrder/IsTheModuleGraphsHistoricalOrder", [] { const std::vector> edges = { {3, 0}, {4, 0}, {4, 1}, {5, 3}, {5, 4}, {6, 2}, {7, 6}, {7, 5}, {1, 2}, }; auto order = g::stack_topological_order(8, edges); - ASSERT_TRUE(order.has_value()); - EXPECT_EQ(*order, reference_stack_order(8, edges)); + expect(fatal(order.has_value())); + expect(eq(*order, reference_stack_order(8, edges))); // It is not the stable order: ties go to the most recently readied node. g::AdjacencyList deps(8); for (auto [c, p] : edges) deps[c].push_back(p); - EXPECT_NE(*order, *g::topological_order(deps)); -} + expect(ne(*order, *g::topological_order(deps))); +}}; -TEST(StackTopologicalOrder, ACycleIsReportedAsItsPath) { +UTest a_cycle_is_reported_as_its_path{"StackTopologicalOrder/ACycleIsReportedAsItsPath", [] { const std::vector> edges = {{0, 1}, {1, 2}, {2, 0}}; auto order = g::stack_topological_order(3, edges); - ASSERT_FALSE(order.has_value()); + expect(fatal(!order.has_value())); auto const& ring = order.error().cycle; - ASSERT_GE(ring.size(), 2u); - EXPECT_EQ(ring.front(), ring.back()); -} + expect(fatal(ge(ring.size(), 2u))); + expect(eq(ring.front(), ring.back())); +}}; // ── depth_first_order: the host-module orders ─────────────────────────────── -TEST(DepthFirstOrder, DependenciesFirstFromEachRootInOrder) { +UTest dependencies_first_from_each_root_in_order{"DepthFirstOrder/DependenciesFirstFromEachRootInOrder", [] { // 0 -> {2, 1}, 1 -> {3}, 2 -> {3}: from root 0, dependency 2 is visited // before 1 because that is the order `deps[0]` lists them in. g::AdjacencyList deps(4); @@ -298,35 +300,35 @@ TEST(DepthFirstOrder, DependenciesFirstFromEachRootInOrder) { deps[1] = {3}; deps[2] = {3}; auto order = g::depth_first_order(deps, std::array{std::size_t{0}}); - ASSERT_TRUE(order.has_value()); - EXPECT_EQ(*order, (std::vector{3, 2, 1, 0})); -} + expect(fatal(order.has_value())); + expect(eq(*order, (std::vector{3, 2, 1, 0}))); +}}; -TEST(DepthFirstOrder, OnlyWhatTheRootsReachIsEmitted) { +UTest only_what_the_roots_reach_is_emitted{"DepthFirstOrder/OnlyWhatTheRootsReachIsEmitted", [] { g::AdjacencyList deps(4); deps[1] = {0}; deps[3] = {2}; auto order = g::depth_first_order(deps, std::array{std::size_t{1}}); - ASSERT_TRUE(order.has_value()); - EXPECT_EQ(*order, (std::vector{0, 1})); -} + expect(fatal(order.has_value())); + expect(eq(*order, (std::vector{0, 1}))); +}}; -TEST(DepthFirstOrder, ACycleIsAnErrorNamingTheRing) { +UTest a_cycle_is_an_error_naming_the_ring{"DepthFirstOrder/ACycleIsAnErrorNamingTheRing", [] { g::AdjacencyList deps(3); deps[0] = {1}; deps[1] = {2}; deps[2] = {1}; auto order = g::depth_first_order(deps, std::array{std::size_t{0}}); - ASSERT_FALSE(order.has_value()); - EXPECT_EQ(order.error().cycle, (std::vector{1, 2, 1})); -} + expect(fatal(!order.has_value())); + expect(eq(order.error().cycle, (std::vector{1, 2, 1}))); +}}; -TEST(DepthFirstOrder, ACycleCanBeSkipped) { +UTest a_cycle_can_be_skipped{"DepthFirstOrder/ACycleCanBeSkipped", [] { g::AdjacencyList deps(2); deps[0] = {1}; deps[1] = {0}; auto order = g::depth_first_order(deps, std::array{std::size_t{0}, std::size_t{1}}, g::Cycles::Skip); - ASSERT_TRUE(order.has_value()); - EXPECT_EQ(*order, (std::vector{1, 0})); -} + expect(fatal(order.has_value())); + expect(eq(*order, (std::vector{1, 0}))); +}}; diff --git a/modules/manifest/mcpp.toml b/modules/manifest/mcpp.toml index 80892ae6b..d42dc2f4f 100644 --- a/modules/manifest/mcpp.toml +++ b/modules/manifest/mcpp.toml @@ -30,4 +30,4 @@ mcpplibs.os = "0.4.0" # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. [dev-dependencies] -compat.gtest = "1.15.2" +mcpplibs.mtest = "0.4.1" diff --git a/modules/manifest/tests/test_compat_registry.cpp b/modules/manifest/tests/test_compat_registry.cpp index 55c781a19..e2dd3130e 100644 --- a/modules/manifest/tests/test_compat_registry.cpp +++ b/modules/manifest/tests/test_compat_registry.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pm.compat.registry; import mcpp.version; +using namespace mcpplibs::mtest; + // THE DEPRECATION REGISTRY (mcpp#797, docs/51 §Deprecation). Every reading a // user can write and that is to be removed is a row; the warning, `mcpp why // compat`, docs/51 and the removal itself are derived from it. These tests @@ -15,64 +17,63 @@ using mcpp::pm::compat::kDeprecations; // THE FORCING FUNCTION. Once mcpp reaches a row's floor, the reading must be // gone from the code, not merely past its date: this fails the build of the // release that would otherwise ship it, naming the row. -TEST(CompatRegistry, NoReadingOutlivesItsFloor) { +UTest no_reading_outlives_its_floor{"CompatRegistry/NoReadingOutlivesItsFloor", [] { for (auto const& d : kDeprecations) - EXPECT_FALSE(mcpp::pm::compat::past_floor(d, mcpp::MCPP_VERSION)) + expect(!mcpp::pm::compat::past_floor(d, mcpp::MCPP_VERSION)) << "mcpp " << mcpp::MCPP_VERSION << " is at or past the floor " << d.floor << " of COMPAT(" << d.id << "): remove the reading and its row"; -} +}}; // docs/51: at least two whole calendar months between the release that // deprecates a reading and the floor from which it is removed. -TEST(CompatRegistry, EveryFloorLeavesTwoWholeMonths) { +UTest every_floor_leaves_two_whole_months{"CompatRegistry/EveryFloorLeavesTwoWholeMonths", [] { for (auto const& d : kDeprecations) { auto since = calver_month(d.since); auto floor = calver_month(d.floor); - ASSERT_TRUE(since && floor) << d.id; + expect(fatal(since && floor)) << d.id; const int months = (floor->first - since->first) * 12 + (floor->second - since->second); // The month of `since` itself is not whole: two more must follow it. - EXPECT_GE(months, 3) << d.id << ": " << d.since << " -> " << d.floor; + expect(ge(months, 3)) << d.id << ": " << d.since << " -> " << d.floor; } -} +}}; -TEST(CompatRegistry, RowsAreCompleteAndUnique) { +UTest rows_are_complete_and_unique{"CompatRegistry/RowsAreCompleteAndUnique", [] { std::set ids; for (auto const& d : kDeprecations) { - EXPECT_TRUE(ids.insert(d.id).second) << "duplicate id " << d.id; - EXPECT_FALSE(d.what.empty()) << d.id; - EXPECT_FALSE(d.canonical.empty()) << d.id; - EXPECT_FALSE(d.domain.empty()) << d.id; - EXPECT_TRUE(d.docs.starts_with("docs/")) << d.id; - EXPECT_NE(mcpp::pm::compat::find_deprecation(d.id), nullptr); + expect(bool(ids.insert(d.id).second)) << "duplicate id " << d.id; + expect(!d.what.empty()) << d.id; + expect(!d.canonical.empty()) << d.id; + expect(!d.domain.empty()) << d.id; + expect(d.docs.starts_with("docs/")) << d.id; + expect(ne(mcpp::pm::compat::find_deprecation(d.id), nullptr)); } - EXPECT_EQ(mcpp::pm::compat::find_deprecation("no-such-reading"), nullptr); -} + expect(eq(mcpp::pm::compat::find_deprecation("no-such-reading"), nullptr)); +}}; -TEST(CompatRegistry, TheClauseNamesBothVersions) { +UTest the_clause_names_both_versions{"CompatRegistry/TheClauseNamesBothVersions", [] { auto const* d = mcpp::pm::compat::find_deprecation("workspace-position"); - ASSERT_NE(d, nullptr); - EXPECT_EQ(mcpp::pm::compat::deprecation_clause(*d), - "deprecated since mcpp 2026.10.10.1 and removed from mcpp 2027.1"); -} + expect(fatal(ne(d, nullptr))); + expect(eq(mcpp::pm::compat::deprecation_clause(*d), "deprecated since mcpp 2026.10.10.1 and removed from mcpp 2027.1")); +}}; -TEST(CompatRegistry, CalverMonthOrdersVersionsAndFloors) { - EXPECT_EQ(calver_month("2026.10.11.1"), (std::pair{2026, 10})); - EXPECT_EQ(calver_month("2027.1"), (std::pair{2027, 1})); - EXPECT_FALSE(calver_month("1.0.0-rc").has_value()); +UTest calver_month_orders_versions_and_floors{"CompatRegistry/CalverMonthOrdersVersionsAndFloors", [] { + expect(eq(calver_month("2026.10.11.1"), (std::pair{2026, 10}))); + expect(eq(calver_month("2027.1"), (std::pair{2027, 1}))); + expect(!(calver_month("1.0.0-rc").has_value())); mcpp::pm::compat::Deprecation d{"x", "2026.10.1.1", "2027.1", "w", "c", "d", "docs/x"}; - EXPECT_FALSE(mcpp::pm::compat::past_floor(d, "2026.12.31.9")); - EXPECT_TRUE(mcpp::pm::compat::past_floor(d, "2027.1.2.1")); - EXPECT_TRUE(mcpp::pm::compat::past_floor(d, "2028.3.1.1")); -} + expect(!mcpp::pm::compat::past_floor(d, "2026.12.31.9")); + expect(bool(mcpp::pm::compat::past_floor(d, "2027.1.2.1"))); + expect(bool(mcpp::pm::compat::past_floor(d, "2028.3.1.1"))); +}}; -TEST(CompatRegistry, UsesAreRecordedOnceAndKeepTheWarning) { +UTest uses_are_recorded_once_and_keep_the_warning{"CompatRegistry/UsesAreRecordedOnceAndKeepTheWarning", [] { mcpp::pm::compat::record_use("workspace-position", "/w/mcpp.toml", "[toolchain]", false); mcpp::pm::compat::record_use("workspace-position", "/w/mcpp.toml", "[toolchain]", true); std::size_t n = 0; for (auto const& u : mcpp::pm::compat::recorded_uses()) if (u.where == "/w/mcpp.toml" && u.detail == "[toolchain]") { ++n; - EXPECT_TRUE(u.warned); + expect(bool(u.warned)); } - EXPECT_EQ(n, 1u); -} + expect(eq(n, 1u)); +}}; diff --git a/modules/manifest/tests/test_flag_words.cpp b/modules/manifest/tests/test_flag_words.cpp index e997fc238..41ae1c864 100644 --- a/modules/manifest/tests/test_flag_words.cpp +++ b/modules/manifest/tests/test_flag_words.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.manifest.flag_words; +using namespace mcpplibs::mtest; + // SUBSYSTEM-LEVEL. The words an element of a compile-flag list stands for are // part of what a manifest MEANS, so the syntax is stated here, with nothing // else present. That the build and the compile databases both use it is @@ -15,7 +17,7 @@ using Words = std::vector; // Each row is a spelling found in a manifest or a descriptor and the words it // stands for on every host. The first rows are the ones #655 measured. -TEST(FlagWords, TheSyntaxTable) { +UTest the_syntax_table{"FlagWords/TheSyntaxTable", [] { const std::vector> rows{ // libarchive's descriptor, the one quoted spelling the index publishes. {R"(-DPLATFORM_CONFIG_H=\"mcpp_libarchive_config.h\")", @@ -61,23 +63,23 @@ TEST(FlagWords, TheSyntaxTable) { {R"(-DV=a\)", {R"(-DV=a\)"}}, }; for (auto const& [element, words] : rows) - EXPECT_EQ(flag_words(element), words) << "element: " << element; -} + expect(eq(flag_words(element), words)) << "element: " << element; +}}; -TEST(FlagWords, AListIsTheConcatenationOfItsElements) { +UTest a_list_is_the_concatenation_of_its_elements{"FlagWords/AListIsTheConcatenationOfItsElements", [] { const std::vector list{"-include x.h", R"(-I"my dir")", "", "-O2"}; - EXPECT_EQ(flag_words(list), (Words{"-include", "x.h", "-Imy dir", "-O2"})); -} + expect(eq(flag_words(list), (Words{"-include", "x.h", "-Imy dir", "-O2"}))); +}}; -TEST(FlagWords, APlainWordIsItsOwnElement) { +UTest a_plain_word_is_its_own_element{"FlagWords/APlainWordIsItsOwnElement", [] { for (std::string_view w : {"-O2", "-DV=1", "-IC:/x", "/std:c++latest", "-Wl,-z,defs", "$x", R"(-IC:\sdk\include)", R"(C:\dir\)"}) - EXPECT_EQ(flag_element(w), w); -} + expect(eq(flag_element(w), w)); +}}; // The property the engine relies on when it inserts a value into a flag list: // the element it writes reads back as exactly that one word. -TEST(FlagWords, AnElementReadsBackAsItsWord) { +UTest an_element_reads_back_as_its_word{"FlagWords/AnElementReadsBackAsItsWord", [] { Words words{"", " ", "a b", "'", "\"", "\\", "a\\", "\\\\srv\\share", "it's", "-DV=\"def\"", "-DV='c'", "x\ty", "a\"b'c\\d e", "-DT=long long", "-DV=\"a b\"", "/DV=x y"}; @@ -95,15 +97,13 @@ TEST(FlagWords, AnElementReadsBackAsItsWord) { words.push_back(std::move(w)); } for (auto const& w : words) - EXPECT_EQ(flag_words(flag_element(w)), Words{w}) << "word: [" << w << "]"; -} + expect(eq(flag_words(flag_element(w)), Words{w})) << "word: [" << w << "]"; +}}; -TEST(HostCommandWords, PosixUndoesShellQuoting) { - EXPECT_EQ(host_command_words(R"(a 'b c' "d\"e" f\ g "h\x" -DV="mid")", false), - (Words{"a", "b c", "d\"e", "f g", "h\\x", "-DV=mid"})); -} +UTest posix_undoes_shell_quoting{"HostCommandWords/PosixUndoesShellQuoting", [] { + expect(eq(host_command_words(R"(a 'b c' "d\"e" f\ g "h\x" -DV="mid")", false), (Words{"a", "b c", "d\"e", "f g", "h\\x", "-DV=mid"}))); +}}; -TEST(HostCommandWords, WindowsFollowsTheRuntimeRules) { - EXPECT_EQ(host_command_words(R"(a "b c" "d\"e" C:\x\ "y\\" z\\\"w 'q')", true), - (Words{"a", "b c", "d\"e", "C:\\x\\", "y\\", "z\\\"w", "'q'"})); -} +UTest windows_follows_the_runtime_rules{"HostCommandWords/WindowsFollowsTheRuntimeRules", [] { + expect(eq(host_command_words(R"(a "b c" "d\"e" C:\x\ "y\\" z\\\"w 'q')", true), (Words{"a", "b c", "d\"e", "C:\\x\\", "y\\", "z\\\"w", "'q'"}))); +}}; diff --git a/modules/manifest/tests/test_selector_grammar.cpp b/modules/manifest/tests/test_selector_grammar.cpp index 7b32ff897..af0ad1d29 100644 --- a/modules/manifest/tests/test_selector_grammar.cpp +++ b/modules/manifest/tests/test_selector_grammar.cpp @@ -1,31 +1,33 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pm.dependency_selector; +using namespace mcpplibs::mtest; + // SUBSYSTEM-LEVEL. A dependency selector is part of what a manifest MEANS, so // it lives here rather than in a package of its own -- and it is testable with // nothing else present, which is the check that the layering is real. -TEST(SelectorGrammar, ADottedSelectorSplitsIntoItsSegments) { +UTest a_dotted_selector_splits_into_its_segments{"SelectorGrammar/ADottedSelectorSplitsIntoItsSegments", [] { auto s = mcpp::pm::split_dependency_selector("mcpplibs.capi.lua"); - ASSERT_EQ(s.size(), 3u); - EXPECT_EQ(s[0], "mcpplibs"); - EXPECT_EQ(s[1], "capi"); - EXPECT_EQ(s[2], "lua"); -} + expect(fatal(eq(s.size(), 3u))); + expect(eq(s[0], "mcpplibs")); + expect(eq(s[1], "capi")); + expect(eq(s[2], "lua")); +}}; -TEST(SelectorGrammar, ABareNameIsOneSegmentAndNotAnError) { +UTest a_bare_name_is_one_segment_and_not_an_error{"SelectorGrammar/ABareNameIsOneSegmentAndNotAnError", [] { // The unqualified spelling is the common case and resolves through the // namespace ladder later. Treating it as malformed here would move that // decision into the grammar, where the ladder is not visible. auto s = mcpp::pm::split_dependency_selector("cmdline"); - ASSERT_EQ(s.size(), 1u); - EXPECT_EQ(s[0], "cmdline"); -} + expect(fatal(eq(s.size(), 1u))); + expect(eq(s[0], "cmdline")); +}}; -TEST(SelectorGrammar, JoiningIsTheInverseOfSplitting) { +UTest joining_is_the_inverse_of_splitting{"SelectorGrammar/JoiningIsTheInverseOfSplitting", [] { const std::string original = "mcpplibs.capi.lua"; auto s = mcpp::pm::split_dependency_selector(original); - EXPECT_EQ(mcpp::pm::join_dependency_segments(s, 0, s.size()), original); -} + expect(eq(mcpp::pm::join_dependency_segments(s, 0, s.size()), original)); +}}; diff --git a/modules/toolchain-model/mcpp.toml b/modules/toolchain-model/mcpp.toml index eaf249c82..4704548df 100644 --- a/modules/toolchain-model/mcpp.toml +++ b/modules/toolchain-model/mcpp.toml @@ -29,7 +29,7 @@ mcpplibs.os = "0.4.0" # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. [dev-dependencies] -compat.gtest = "1.15.2" +mcpplibs.mtest = "0.4.1" # build.mcpp compiles data/builtin.toml (the built-in target rows and providers) # into a module of static tables, with mcpp's own TOML parser. diff --git a/modules/toolchain-model/tests/test_platform_axis.cpp b/modules/toolchain-model/tests/test_platform_axis.cpp index f508437b8..8df684e2c 100644 --- a/modules/toolchain-model/tests/test_platform_axis.cpp +++ b/modules/toolchain-model/tests/test_platform_axis.cpp @@ -1,32 +1,34 @@ -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.platform; +using namespace mcpplibs::mtest; + // SUBSYSTEM-LEVEL. This package imports nothing from mcpp, so these tests can // only see what the package itself promises — which is the point. The // engine-side tests in the root `tests/unit/` exercise the same vocabulary // through resolution, so a failure here means the vocabulary changed, while a // failure there means a consumer was relying on more than it was told. -TEST(PlatformAxis, TheHostPlatformKeyIsNeverEmpty) { +UTest the_host_platform_key_is_never_empty{"PlatformAxis/TheHostPlatformKeyIsNeverEmpty", [] { // Every descriptor lookup is keyed on this. An empty key would not fail — // it would match the section named "", which no descriptor has, and the // package would simply appear to be unavailable everywhere. auto host = mcpp::toolchain::platform::HostPlatform::current(); - EXPECT_FALSE(host.key().empty()); -} + expect(!(host.key().empty())); +}}; -TEST(PlatformAxis, TwoKeysCompareByValue) { +UTest two_keys_compare_by_value{"PlatformAxis/TwoKeysCompareByValue", [] { // `PlatformKey` is only obtainable through an axis type, so possession of // one already says which axis it came from. Equality therefore has to be // about the key and not the object. auto a = mcpp::toolchain::platform::HostPlatform::current(); auto b = mcpp::toolchain::platform::HostPlatform::current(); - EXPECT_TRUE(a == b); -} + expect(a == b); +}}; -TEST(PlatformAxis, ATargetReadsItsRowsBlocksAndNeverTheHosts) { +UTest a_target_reads_its_rows_blocks_and_never_the_hosts{"PlatformAxis/ATargetReadsItsRowsBlocksAndNeverTheHosts", [] { // Design 2026-10-29 §5.1. Before 2026.10.30.1 a token naming none of the // three fell back to the HOST's block (#851). using mcpp::toolchain::platform::TargetPlatform; @@ -34,17 +36,17 @@ TEST(PlatformAxis, ATargetReadsItsRowsBlocksAndNeverTheHosts) { auto t = TargetPlatform::for_os(os); return std::vector(t.keys().begin(), t.keys().end()); }; - EXPECT_EQ(keys("linux"), (std::vector{"linux"})); - EXPECT_EQ(keys("macos"), (std::vector{"macosx"})); - EXPECT_EQ(keys("windows"), (std::vector{"windows"})); - EXPECT_EQ(keys("emscripten"), (std::vector{"emscripten", "linux"})); - EXPECT_EQ(keys("ios"), (std::vector{"ios", "macosx"})); - EXPECT_EQ(keys("openkal"), (std::vector{"openkal", "linux"})); - EXPECT_EQ(keys("none"), (std::vector{"none"})); + expect(eq(keys("linux"), (std::vector{"linux"}))); + expect(eq(keys("macos"), (std::vector{"macosx"}))); + expect(eq(keys("windows"), (std::vector{"windows"}))); + expect(eq(keys("emscripten"), (std::vector{"emscripten", "linux"}))); + expect(eq(keys("ios"), (std::vector{"ios", "macosx"}))); + expect(eq(keys("openkal"), (std::vector{"openkal", "linux"}))); + expect(eq(keys("none"), (std::vector{"none"}))); // The primary key is the one messages name. - EXPECT_EQ(TargetPlatform::for_os("emscripten").key(), "emscripten"); + expect(eq(TargetPlatform::for_os("emscripten").key(), "emscripten")); // A platform's own keys replace the row's. auto stated = TargetPlatform::with_keys({"openkal"}); - EXPECT_EQ(stated.key(), "openkal"); - EXPECT_FALSE(stated == TargetPlatform::for_os("openkal")); -} + expect(eq(stated.key(), "openkal")); + expect(!(stated == TargetPlatform::for_os("openkal"))); +}}; diff --git a/modules/toolchain-model/tests/test_triple_vocabulary.cpp b/modules/toolchain-model/tests/test_triple_vocabulary.cpp index e750f863c..e2667d159 100644 --- a/modules/toolchain-model/tests/test_triple_vocabulary.cpp +++ b/modules/toolchain-model/tests/test_triple_vocabulary.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.triple; +using namespace mcpplibs::mtest; + // SUBSYSTEM-LEVEL. This package is the toolchain VOCABULARY -- what a triple // is, what a dialect is -- separated from finding a toolchain on a machine. // These tests see no detection, no registry and no xlings, which is what makes @@ -10,49 +12,49 @@ import mcpp.toolchain.triple; namespace tr = mcpp::toolchain::triple; -TEST(TripleVocabulary, TheThreePartSpellingMeansTheSameAsTheFourPart) { +UTest the_three_part_spelling_means_the_same_as_the_four_part{"TripleVocabulary/TheThreePartSpellingMeansTheSameAsTheFourPart", [] { // Users write the short form; the resolver, the lock file and the cache key // all read the long one. If these ever stopped agreeing, one project would // build twice under two names. auto three = tr::parse("x86_64-linux-musl"); auto four = tr::parse("x86_64-unknown-linux-musl"); - ASSERT_TRUE(three.has_value()); - ASSERT_TRUE(four.has_value()); - EXPECT_EQ(three->arch, four->arch); - EXPECT_EQ(three->os, four->os); - EXPECT_EQ(three->env, four->env); -} + expect(fatal(three.has_value())); + expect(fatal(four.has_value())); + expect(eq(three->arch, four->arch)); + expect(eq(three->os, four->os)); + expect(eq(three->env, four->env)); +}}; -TEST(TripleVocabulary, TheHostTripleIsParseable) { +UTest the_host_triple_is_parseable{"TripleVocabulary/TheHostTripleIsParseable", [] { // `host_triple()` is the MCPP_HOST contract value handed to every build // program. A value this package cannot parse back is one no consumer can // act on. auto h = tr::host_triple(); - EXPECT_FALSE(h.arch.empty()); - EXPECT_FALSE(h.os.empty()); -} + expect(!h.arch.empty()); + expect(!h.os.empty()); +}}; -TEST(TripleVocabulary, AnUnknownTripleIsNotSilentlyKnown) { +UTest an_unknown_triple_is_not_silently_known{"TripleVocabulary/AnUnknownTripleIsNotSilentlyKnown", [] { // `is_known_target` gates the per-target table. Answering "yes" for // something absent from it would select an empty row rather than report an // unsupported target. auto t = tr::parse("nosucharch-unknown-nosuchos-nosuchenv"); - if (t.has_value()) EXPECT_FALSE(tr::is_known_target(*t)); -} + if (t.has_value()) expect(!tr::is_known_target(*t)); +}}; -TEST(TripleVocabulary, MsvcsX86IsWrittenI686AndTheOtherSpellingsKeepTheirMeaning) { +UTest msvcs_x86_is_written_i686_and_the_other_spellings_keep_their_meaning{"TripleVocabulary/MsvcsX86IsWrittenI686AndTheOtherSpellingsKeepTheirMeaning", [] { // `x86` is MSVC's name for the ISA and no LLVM tool takes it as an // architecture, so it is written the way `amd64` and `arm64` are: once, at // the parse. i386-i586 are different baseline CPUs to clang and stay. auto x86 = tr::parse("x86-windows-msvc"); - ASSERT_TRUE(x86.has_value()); - EXPECT_EQ(x86->str(), "i686-windows-msvc"); - EXPECT_EQ(x86->llvm_triple(), "i686-pc-windows-msvc"); - EXPECT_EQ(*tr::parse("x86-pc-windows-msvc"), *tr::parse("i686-windows-msvc")); - EXPECT_EQ(tr::parse("i386-windows-msvc")->llvm_triple(), "i386-pc-windows-msvc"); -} + expect(fatal(x86.has_value())); + expect(eq(x86->str(), "i686-windows-msvc")); + expect(eq(x86->llvm_triple(), "i686-pc-windows-msvc")); + expect(eq(*tr::parse("x86-pc-windows-msvc"), *tr::parse("i686-windows-msvc"))); + expect(eq(tr::parse("i386-windows-msvc")->llvm_triple(), "i386-pc-windows-msvc")); +}}; -TEST(TripleVocabulary, The32BitX86QuestionHasOneAnswer) { +UTest the32_bit_x86_question_has_one_answer{"TripleVocabulary/The32BitX86QuestionHasOneAnswer", [] { struct Row { std::string_view triple; bool x86_32; std::string_view msvc; }; for (auto const& r : { Row{"x86-windows-msvc", true, "x86"}, @@ -66,10 +68,10 @@ TEST(TripleVocabulary, The32BitX86QuestionHasOneAnswer) { Row{"arm64-apple-macos", false, "arm64"}, Row{"riscv64-linux-musl", false, ""}}) { auto t = tr::parse(r.triple); - ASSERT_TRUE(t.has_value()) << r.triple; - EXPECT_EQ(t->is_x86_32(), r.x86_32) << r.triple; - EXPECT_EQ(t->msvc_arch(), r.msvc) << r.triple; + expect(fatal(t.has_value())) << r.triple; + expect(eq(t->is_x86_32(), r.x86_32)) << r.triple; + expect(eq(t->msvc_arch(), r.msvc)) << r.triple; } - EXPECT_EQ(tr::parse("i586-linux-gnu")->nasm_format(), "elf32"); - EXPECT_EQ(tr::parse("x86-windows-msvc")->nasm_format(), "win32"); -} + expect(eq(tr::parse("i586-linux-gnu")->nasm_format(), "elf32")); + expect(eq(tr::parse("x86-windows-msvc")->nasm_format(), "win32")); +}}; diff --git a/modules/versioning/mcpp.toml b/modules/versioning/mcpp.toml index 88ea1d8b6..0c8f15695 100644 --- a/modules/versioning/mcpp.toml +++ b/modules/versioning/mcpp.toml @@ -25,4 +25,4 @@ license = "Apache-2.0" # state its contract in isolation; the root `tests/unit/` states the same facts # as the engine consumes them, and the pair is the cross-check. [dev-dependencies] -compat.gtest = "1.15.2" +mcpplibs.mtest = "0.4.1" diff --git a/modules/versioning/tests/test_version_grammar.cpp b/modules/versioning/tests/test_version_grammar.cpp index 8b4e2a396..49864cd44 100644 --- a/modules/versioning/tests/test_version_grammar.cpp +++ b/modules/versioning/tests/test_version_grammar.cpp @@ -1,44 +1,46 @@ -#include import std; +import mcpplibs.mtest; import mcpp.version; import mcpp.version_req; +using namespace mcpplibs::mtest; + // SUBSYSTEM-LEVEL: the two modules here answer different questions and are // tested for that difference. `mcpp.version` is this binary's identity; // `mcpp.version_req` is the grammar every OTHER package's version is read // with. Nothing outside this package is involved. -TEST(Versioning, ThisBinaryDeclaresAFourSegmentDateVersion) { +UTest this_binary_declares_a_four_segment_date_version{"Versioning/ThisBinaryDeclaresAFourSegmentDateVersion", [] { // The same string lives in the root mcpp.toml, and // .github/tools/check_version_pins.sh keeps the two equal. This states the // SHAPE, which that checker cannot: a version that lost a segment would // still match the manifest and still be wrong. const std::string v{mcpp::MCPP_VERSION}; - EXPECT_FALSE(v.empty()); - EXPECT_EQ(std::ranges::count(v, '.'), 3) << "expected YYYY.M.D.N, got " << v; -} + expect(!v.empty()); + expect(eq(std::ranges::count(v, '.'), 3)) << "expected YYYY.M.D.N, got " << v; +}}; -TEST(Versioning, TheFourthSegmentSurvivesAParseAndPrint) { +UTest the_fourth_segment_survives_a_parse_and_print{"Versioning/TheFourthSegmentSurvivesAParseAndPrint", [] { // Round-tripping is load-bearing: the resolver rebuilds dependency version // strings from this, and they flow into the lock file and the xlings wire // address. A `.0` collapsing to three segments would rename a package. auto v = mcpp::version_req::parse_version("2026.8.29.1"); - ASSERT_TRUE(v.has_value()) << v.error(); - EXPECT_EQ(v->str(), "2026.8.29.1"); + if (auto&& failed = expect(fatal(v.has_value())); !failed) failed << v.error(); + expect(eq(v->str(), "2026.8.29.1")); auto z = mcpp::version_req::parse_version("2026.8.29.0"); - ASSERT_TRUE(z.has_value()) << z.error(); - EXPECT_EQ(z->str(), "2026.8.29.0"); -} + if (auto&& failed = expect(fatal(z.has_value())); !failed) failed << z.error(); + expect(eq(z->str(), "2026.8.29.0")); +}}; -TEST(Versioning, TheDateSchemeSortsAboveTheOldThreeSegmentOne) { +UTest the_date_scheme_sorts_above_the_old_three_segment_one{"Versioning/TheDateSchemeSortsAboveTheOldThreeSegmentOne", [] { // `0.0.109` < `2026.7.27.1`: the first segment goes from 0 to 2026 and // never comes back. Stated here because the ordering is what stops a // released mcpp from resolving backwards into the pre-date scheme. auto older = mcpp::version_req::parse_version("0.0.109"); auto newer = mcpp::version_req::parse_version("2026.7.27.1"); - ASSERT_TRUE(older.has_value()); - ASSERT_TRUE(newer.has_value()); - EXPECT_TRUE(*older < *newer); -} + expect(fatal(older.has_value())); + expect(fatal(newer.has_value())); + expect(*older < *newer); +}}; diff --git a/modules/versioning/tests/test_xpkg_version.cpp b/modules/versioning/tests/test_xpkg_version.cpp index 80ad988a4..ae8655801 100644 --- a/modules/versioning/tests/test_xpkg_version.cpp +++ b/modules/versioning/tests/test_xpkg_version.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.xpkg_version; +using namespace mcpplibs::mtest; + // SUBSYSTEM-LEVEL: the grammar xlings resolves an `[xlings]` address with, // stated on its own. The engine-level consequence (which payload directory // `mcpp::xpkg_dir` answers) is in tests/unit/test_freestanding.cpp. @@ -16,47 +18,47 @@ std::string best(std::initializer_list keys, std::string_view req) } } -TEST(XpkgVersion, FourSegmentKeysAreOrdinaryVersions) { +UTest four_segment_keys_are_ordinary_versions{"XpkgVersion/FourSegmentKeysAreOrdinaryVersions", [] { auto v = xv::parse("1.7.0.1"); - ASSERT_TRUE(v.has_value()); - EXPECT_EQ(v->components, 4); - EXPECT_GT(xv::compare_keys("1.7.0.1", "1.7.0"), 0); - EXPECT_GT(xv::compare_keys("0.0.100", "0.0.11"), 0); -} + expect(fatal(v.has_value())); + expect(eq(v->components, 4)); + expect(gt(xv::compare_keys("1.7.0.1", "1.7.0"), 0)); + expect(gt(xv::compare_keys("0.0.100", "0.0.11"), 0)); +}}; -TEST(XpkgVersion, ABareVersionOfOneOrTwoSegmentsIsAPrefixRange) { - EXPECT_EQ(best({"1.7.0.1", "1.8.0"}, "1.7"), "1.7.0.1"); - EXPECT_EQ(best({"1.2.0", "1.2.5", "1.3.0"}, "1.2"), "1.2.5"); - EXPECT_EQ(best({"15.1.0", "15.2.0", "16.1.0"}, "15"), "15.2.0"); -} +UTest a_bare_version_of_one_or_two_segments_is_a_prefix_range{"XpkgVersion/ABareVersionOfOneOrTwoSegmentsIsAPrefixRange", [] { + expect(eq(best({"1.7.0.1", "1.8.0"}, "1.7"), "1.7.0.1")); + expect(eq(best({"1.2.0", "1.2.5", "1.3.0"}, "1.2"), "1.2.5")); + expect(eq(best({"15.1.0", "15.2.0", "16.1.0"}, "15"), "15.2.0")); +}}; -TEST(XpkgVersion, ABareVersionOfThreeOrMoreSegmentsIsWrittenPrefixEquality) { - EXPECT_EQ(best({"12.9.1.4", "12.9.10"}, "12.9.1"), "12.9.1.4"); - EXPECT_EQ(best({"1.2.0", "1.2.5"}, "1.2.0"), "1.2.0"); - EXPECT_EQ(best({"1.8.13", "1.9.0"}, "1.8.12"), ""); - EXPECT_EQ(best({"2.15.0.1", "2.15.0.2"}, "2.15.0.1"), "2.15.0.1"); -} +UTest a_bare_version_of_three_or_more_segments_is_written_prefix_equality{"XpkgVersion/ABareVersionOfThreeOrMoreSegmentsIsWrittenPrefixEquality", [] { + expect(eq(best({"12.9.1.4", "12.9.10"}, "12.9.1"), "12.9.1.4")); + expect(eq(best({"1.2.0", "1.2.5"}, "1.2.0"), "1.2.0")); + expect(eq(best({"1.8.13", "1.9.0"}, "1.8.12"), "")); + expect(eq(best({"2.15.0.1", "2.15.0.2"}, "2.15.0.1"), "2.15.0.1")); +}}; -TEST(XpkgVersion, OperatorsAndConjunctionsCompareTheWholeVersion) { - EXPECT_EQ(best({"8.0.0", "8.5.0", "8.7.1"}, ">=8.5.0"), "8.7.1"); - EXPECT_EQ(best({"8.0.0", "8.5.0", "8.7.1"}, ">=8.0.0 <8.7.0"), "8.5.0"); - EXPECT_EQ(best({"2.15.0", "2.15.0.1"}, ">=2.15.0.1"), "2.15.0.1"); - EXPECT_EQ(best({"1.2.3", "1.9.0", "2.0.0"}, "^1.2.3"), "1.9.0"); - EXPECT_EQ(best({"1.2.3", "1.2.9", "1.3.0"}, "~1.2.3"), "1.2.9"); - EXPECT_EQ(best({"1.2.3", "1.2.9", "1.3.0"}, "1.2.*"), "1.2.9"); -} +UTest operators_and_conjunctions_compare_the_whole_version{"XpkgVersion/OperatorsAndConjunctionsCompareTheWholeVersion", [] { + expect(eq(best({"8.0.0", "8.5.0", "8.7.1"}, ">=8.5.0"), "8.7.1")); + expect(eq(best({"8.0.0", "8.5.0", "8.7.1"}, ">=8.0.0 <8.7.0"), "8.5.0")); + expect(eq(best({"2.15.0", "2.15.0.1"}, ">=2.15.0.1"), "2.15.0.1")); + expect(eq(best({"1.2.3", "1.9.0", "2.0.0"}, "^1.2.3"), "1.9.0")); + expect(eq(best({"1.2.3", "1.2.9", "1.3.0"}, "~1.2.3"), "1.2.9")); + expect(eq(best({"1.2.3", "1.2.9", "1.3.0"}, "1.2.*"), "1.2.9")); +}}; -TEST(XpkgVersion, APrereleaseIsExactAndRanksBelowItsRelease) { - EXPECT_EQ(best({"1.0.0-rc1", "1.0.0"}, "1.0.0-rc1"), "1.0.0-rc1"); - EXPECT_LT(xv::compare_keys("1.0.0-rc1", "1.0.0"), 0); -} +UTest a_prerelease_is_exact_and_ranks_below_its_release{"XpkgVersion/APrereleaseIsExactAndRanksBelowItsRelease", [] { + expect(eq(best({"1.0.0-rc1", "1.0.0"}, "1.0.0-rc1"), "1.0.0-rc1")); + expect(lt(xv::compare_keys("1.0.0-rc1", "1.0.0"), 0)); +}}; -TEST(XpkgVersion, NamesNeverWinAndBuildMetadataIsIgnored) { - EXPECT_EQ(best({"latest", "nightly", "1.0.0"}, ">=0.1"), "1.0.0"); - EXPECT_FALSE(xv::parse("nightly").has_value()); - EXPECT_GT(xv::compare_keys("1.0.0", "nightly"), 0); - EXPECT_EQ(best({"25.0.4+7"}, "25.0.4"), "25.0.4+7"); -} +UTest names_never_win_and_build_metadata_is_ignored{"XpkgVersion/NamesNeverWinAndBuildMetadataIsIgnored", [] { + expect(eq(best({"latest", "nightly", "1.0.0"}, ">=0.1"), "1.0.0")); + expect(!(xv::parse("nightly").has_value())); + expect(gt(xv::compare_keys("1.0.0", "nightly"), 0)); + expect(eq(best({"25.0.4+7"}, "25.0.4"), "25.0.4+7")); +}}; // THE SHARED STATEMENT. xlings publishes which version a request selects as // data (tests/data/semver-vectors.tsv in its repository), and this package @@ -89,9 +91,9 @@ std::vector split(std::string_view s, char sep) { } } -TEST(XpkgVersion, TheXlingsConformanceVectorsSelectTheSameVersion) { +UTest the_xlings_conformance_vectors_select_the_same_version{"XpkgVersion/TheXlingsConformanceVectorsSelectTheSameVersion", [] { const auto file = vectors_file(); - ASSERT_FALSE(file.empty()) << "semver-vectors.tsv not found above " + expect(fatal(!file.empty())) << "semver-vectors.tsv not found above " << std::filesystem::current_path(); std::ifstream in(file); std::string line; @@ -100,13 +102,12 @@ TEST(XpkgVersion, TheXlingsConformanceVectorsSelectTheSameVersion) { ++lineNo; if (line.empty() || line.front() == '#') continue; const auto col = split(line, '\t'); - ASSERT_EQ(col.size(), 4u) << file << ":" << lineNo; + expect(fatal(eq(col.size(), 4u))) << file << ":" << lineNo; if (col[2] != "-") continue; const auto available = split(col[1], ','); const auto request = col[0] == "-" ? std::string{} : col[0]; - EXPECT_EQ(xv::select_installed(available, request).value_or("none"), col[3]) - << file << ":" << lineNo << ": request '" << col[0] << "' among " << col[1]; + if (auto&& failed = expect(eq(xv::select_installed(available, request).value_or("none"), col[3])); !failed) failed << file << ":" << lineNo << ": request '" << col[0] << "' among " << col[1]; ++checked; } - EXPECT_GT(checked, 0); -} + expect(gt(checked, 0)); +}}; diff --git a/tests/e2e/helpers/check_owned_schedule_lifetime.py b/tests/e2e/helpers/check_owned_schedule_lifetime.py index 9652014e7..e2cf88f21 100644 --- a/tests/e2e/helpers/check_owned_schedule_lifetime.py +++ b/tests/e2e/helpers/check_owned_schedule_lifetime.py @@ -45,7 +45,7 @@ def __init__(self, root, name, binary, kind="module", linked=False, **extra): self.out.parent.symlink_to(cache, target_is_directory=True) self.out.mkdir(parents=True) self.env = os.environ.copy() - self.env.update(GTEST_FILTER="DetachLifetime.SubprocessDriver", + self.env.update(MTEST_PROTOCOL="", MCPP_TEST_LIFETIME_ROLE="root", MCPP_TEST_LIFETIME_KIND=kind, MCPP_TEST_LIFETIME_OUTPUT=str(self.out)) self.env.update(extra) diff --git a/tests/e2e/helpers/check_shared_compiler_cap.py b/tests/e2e/helpers/check_shared_compiler_cap.py index 3cb630885..073f0d186 100644 --- a/tests/e2e/helpers/check_shared_compiler_cap.py +++ b/tests/e2e/helpers/check_shared_compiler_cap.py @@ -135,7 +135,7 @@ def update(start): env["MCPP_COMPILER_CAP"] = str(cap) # build.ninja names no cap; outside a generation it is this command = [ninja, "-j", str(cap * 6)] if owner_driver: - env.update(GTEST_FILTER="DetachLifetime.SubprocessDriver", + env.update(MTEST_PROTOCOL="", MCPP_TEST_LIFETIME_ROLE="root", MCPP_TEST_LIFETIME_OUTPUT=str(build), MCPP_TEST_LIFETIME_NINJA_JOBS=str(cap*6), MCPP_TEST_LIFETIME_CAP=str(cap)) command=[str(owner_driver)] diff --git a/tests/unit/test_abi.cpp b/tests/unit/test_abi.cpp index 75ec7ae59..7459b70f1 100644 --- a/tests/unit/test_abi.cpp +++ b/tests/unit/test_abi.cpp @@ -1,11 +1,13 @@ -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.abi; import mcpp.toolchain.model; import mcpp.manifest; import mcpp.build.prepare; +using namespace mcpplibs::mtest; + using namespace mcpp::toolchain; namespace { @@ -22,82 +24,82 @@ Toolchain make_tc(std::string triple, std::string stdlib, CompilerId cc) { // ─── abi_profile: each dimension from its single canonical source ─────────── -TEST(AbiProfile, ClangLibcxxOnLinuxGnuIsGlibcAtLibcLevel) { +UTest clang_libcxx_on_linux_gnu_is_glibc_at_libc_level{"AbiProfile/ClangLibcxxOnLinuxGnuIsGlibcAtLibcLevel", [] { // The bug: a clang+libc++ toolchain targeting *-linux-gnu is glibc at the // libc level; only its C++ stdlib is libc++. These are independent dims. auto p = abi_profile(make_tc("x86_64-unknown-linux-gnu", "libc++", CompilerId::Clang)); - EXPECT_EQ(p.libc, "glibc"); - EXPECT_EQ(p.cxxStdlib, "libc++"); - EXPECT_EQ(p.arch, "x86_64"); - EXPECT_EQ(p.os, "linux"); - EXPECT_EQ(p.cxxAbi, "itanium"); -} - -TEST(AbiProfile, GccGlibc) { + expect(eq(p.libc, "glibc")); + expect(eq(p.cxxStdlib, "libc++")); + expect(eq(p.arch, "x86_64")); + expect(eq(p.os, "linux")); + expect(eq(p.cxxAbi, "itanium")); +}}; + +UTest gcc_glibc{"AbiProfile/GccGlibc", [] { auto p = abi_profile(make_tc("x86_64-linux-gnu", "libstdc++", CompilerId::GCC)); - EXPECT_EQ(p.libc, "glibc"); - EXPECT_EQ(p.cxxStdlib, "libstdc++"); -} + expect(eq(p.libc, "glibc")); + expect(eq(p.cxxStdlib, "libstdc++")); +}}; -TEST(AbiProfile, MuslTripleIsMusl) { +UTest musl_triple_is_musl{"AbiProfile/MuslTripleIsMusl", [] { auto p = abi_profile(make_tc("x86_64-linux-musl", "libstdc++", CompilerId::GCC)); - EXPECT_EQ(p.libc, "musl"); - EXPECT_EQ(p.os, "linux"); -} + expect(eq(p.libc, "musl")); + expect(eq(p.os, "linux")); +}}; // ─── abi_check: the regression + the genuine incompatibility ──────────────── -TEST(AbiCheck, GlibcCLibraryUnderLibcxxIsCompatible) { +UTest glibc_c_library_under_libcxx_is_compatible{"AbiCheck/GlibcCLibraryUnderLibcxxIsCompatible", [] { // Regression lock for the glfw-under-clang false positive. auto p = abi_profile(make_tc("x86_64-unknown-linux-gnu", "libc++", CompilerId::Clang)); auto c = parse_abi_capability("abi:glibc", "compat.glfw"); - ASSERT_TRUE(c.has_value()); - EXPECT_TRUE(abi_check(p, {*c}).empty()); -} + expect(fatal(c.has_value())); + expect(abi_check(p, {*c}).empty()); +}}; -TEST(AbiCheck, GlibcDepUnderMuslMismatchesOnLibcDim) { +UTest glibc_dep_under_musl_mismatches_on_libc_dim{"AbiCheck/GlibcDepUnderMuslMismatchesOnLibcDim", [] { auto p = abi_profile(make_tc("x86_64-linux-musl", "libstdc++", CompilerId::GCC)); auto c = parse_abi_capability("abi:glibc", "compat.glfw"); - ASSERT_TRUE(c.has_value()); + expect(fatal(c.has_value())); auto mm = abi_check(p, {*c}); - ASSERT_EQ(mm.size(), 1u); - EXPECT_EQ(mm[0].dim, AbiDim::Libc); - EXPECT_EQ(mm[0].need, "glibc"); - EXPECT_EQ(mm[0].got, "musl"); -} + expect(fatal(eq(mm.size(), 1u))); + expect(eq(mm[0].dim, AbiDim::Libc)); + expect(eq(mm[0].need, "glibc")); + expect(eq(mm[0].got, "musl")); +}}; -TEST(AbiCheck, UnspecifiedDimensionIsDontCare) { +UTest unspecified_dimension_is_dont_care{"AbiCheck/UnspecifiedDimensionIsDontCare", [] { // A cxxstdlib constraint must NOT be implied by a bare libc capability. auto p = abi_profile(make_tc("x86_64-unknown-linux-gnu", "libc++", CompilerId::Clang)); - EXPECT_TRUE(abi_check(p, {{AbiDim::Arch, "x86_64", "x"}}).empty()); // matches - EXPECT_FALSE(abi_check(p, {{AbiDim::Arch, "aarch64", "x"}}).empty()); // mismatches -} + expect(abi_check(p, {{AbiDim::Arch, "x86_64", "x"}}).empty()); // matches + expect(!(abi_check(p, {{AbiDim::Arch, "aarch64", "x"}}).empty())); // mismatches +}}; // ─── parse_abi_capability: legacy + dimensional forms ─────────────────────── -TEST(AbiCapability, LegacyBareFormIsLibc) { +UTest legacy_bare_form_is_libc{"AbiCapability/LegacyBareFormIsLibc", [] { auto c = parse_abi_capability("abi:glibc", "pkg"); - ASSERT_TRUE(c.has_value()); - EXPECT_EQ(c->dim, AbiDim::Libc); - EXPECT_EQ(c->value, "glibc"); - EXPECT_EQ(c->source, "pkg"); -} + expect(fatal(c.has_value())); + expect(eq(c->dim, AbiDim::Libc)); + expect(eq(c->value, "glibc")); + expect(eq(c->source, "pkg")); +}}; -TEST(AbiCapability, DimensionalForm) { +UTest dimensional_form{"AbiCapability/DimensionalForm", [] { auto c = parse_abi_capability("abi:cxxstdlib=libc++"); - ASSERT_TRUE(c.has_value()); - EXPECT_EQ(c->dim, AbiDim::CxxStdlib); - EXPECT_EQ(c->value, "libc++"); -} + expect(fatal(c.has_value())); + expect(eq(c->dim, AbiDim::CxxStdlib)); + expect(eq(c->value, "libc++")); +}}; -TEST(AbiCapability, NonAbiCapabilityIgnored) { - EXPECT_FALSE(parse_abi_capability("x11.display").has_value()); - EXPECT_FALSE(parse_abi_capability("opengl.glx.driver").has_value()); -} +UTest non_abi_capability_ignored{"AbiCapability/NonAbiCapabilityIgnored", [] { + expect(!(parse_abi_capability("x11.display").has_value())); + expect(!(parse_abi_capability("opengl.glx.driver").has_value())); +}}; -TEST(AbiCapability, UnknownDimensionIgnored) { - EXPECT_FALSE(parse_abi_capability("abi:nonsense=1").has_value()); -} +UTest unknown_dimension_ignored{"AbiCapability/UnknownDimensionIgnored", [] { + expect(!(parse_abi_capability("abi:nonsense=1").has_value())); +}}; // ─── `[target..abi] exceptions` and `requires_abi` on the target axis ── // @@ -126,7 +128,7 @@ const mcpp::manifest::ConditionalConfig* find_cc( } // namespace manifest_abi -TEST(AbiTableExceptions, ParsesAndReachesTheConditionalConfig) { +UTest parses_and_reaches_the_conditional_config{"AbiTableExceptions/ParsesAndReachesTheConditionalConfig", [] { constexpr auto src = R"( [package] name = "webrt" @@ -135,17 +137,17 @@ version = "0.1.0" exceptions = true )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto* cc = manifest_abi::find_cc(*m, "cfg(os = \"emscripten\")"); - ASSERT_NE(cc, nullptr); - EXPECT_TRUE(cc->abiExceptionsDeclared); - EXPECT_TRUE(cc->abiExceptions); + expect(fatal(ne(cc, nullptr))); + expect(bool(cc->abiExceptionsDeclared)); + expect(bool(cc->abiExceptions)); // `threads` is untouched by a table that only names `exceptions` -- // the two members are independent switches, not a package deal. - EXPECT_FALSE(cc->abiThreadsDeclared); -} + expect(!cc->abiThreadsDeclared); +}}; -TEST(AbiTableExceptions, BothMembersInOneTableParseIndependently) { +UTest both_members_in_one_table_parse_independently{"AbiTableExceptions/BothMembersInOneTableParseIndependently", [] { constexpr auto src = R"( [package] name = "webrt" @@ -155,16 +157,16 @@ threads = true exceptions = false )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto* cc = manifest_abi::find_cc(*m, "cfg(os = \"emscripten\")"); - ASSERT_NE(cc, nullptr); - EXPECT_TRUE(cc->abiThreadsDeclared); - EXPECT_TRUE(cc->abiThreads); - EXPECT_TRUE(cc->abiExceptionsDeclared); - EXPECT_FALSE(cc->abiExceptions); -} - -TEST(AbiTableExceptions, UnknownMemberNamesBothThreadsAndExceptions) { + expect(fatal(ne(cc, nullptr))); + expect(bool(cc->abiThreadsDeclared)); + expect(bool(cc->abiThreads)); + expect(bool(cc->abiExceptionsDeclared)); + expect(!cc->abiExceptions); +}}; + +UTest unknown_member_names_both_threads_and_exceptions{"AbiTableExceptions/UnknownMemberNamesBothThreadsAndExceptions", [] { constexpr auto src = R"( [package] name = "a" @@ -173,13 +175,12 @@ version = "0.1.0" frobnicate = true )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find( - "has no member 'frobnicate'; the members are: threads, exceptions"), - std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find( + "has no member 'frobnicate'; the members are: threads, exceptions"), std::string::npos)); !failed) failed << m.error().message; +}}; -TEST(AbiTableExceptions, ANonBooleanExceptionsIsRefused) { +UTest a_non_boolean_exceptions_is_refused{"AbiTableExceptions/ANonBooleanExceptionsIsRefused", [] { constexpr auto src = R"( [package] name = "a" @@ -188,14 +189,13 @@ version = "0.1.0" exceptions = 1 )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find(".exceptions must be true, false, \"wasm\" or \"js\""), - std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find(".exceptions must be true, false, \"wasm\" or \"js\""), std::string::npos)); !failed) failed << m.error().message; +}}; // ─── `requires_abi` as a direct key of `[target.]` ───────────────────── -TEST(RequiresAbiOnTargetAxis, SelectorScopedRequirementParsesIntoTheConditionalConfig) { +UTest selector_scoped_requirement_parses_into_the_conditional_config{"RequiresAbiOnTargetAxis/SelectorScopedRequirementParsesIntoTheConditionalConfig", [] { constexpr auto src = R"( [package] name = "adapter" @@ -204,14 +204,14 @@ version = "0.1.0" requires_abi = { threads = true } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto* cc = manifest_abi::find_cc(*m, "cfg(linux)"); - ASSERT_NE(cc, nullptr); - EXPECT_TRUE(cc->requiresAbiThreads); - EXPECT_FALSE(cc->requiresAbiExceptions); -} + expect(fatal(ne(cc, nullptr))); + expect(bool(cc->requiresAbiThreads)); + expect(!cc->requiresAbiExceptions); +}}; -TEST(RequiresAbiOnTargetAxis, FeatureFormParsesUnderTheSelector) { +UTest feature_form_parses_under_the_selector{"RequiresAbiOnTargetAxis/FeatureFormParsesUnderTheSelector", [] { constexpr auto src = R"( [package] name = "adapter" @@ -220,18 +220,18 @@ version = "0.1.0" mt = { threads = true } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto* cc = manifest_abi::find_cc(*m, "cfg(linux)"); - ASSERT_NE(cc, nullptr); - ASSERT_TRUE(cc->featureRequiresAbiThreads.contains("mt")); - EXPECT_TRUE(cc->featureRequiresAbiThreads.at("mt")); + expect(fatal(ne(cc, nullptr))); + expect(fatal(cc->featureRequiresAbiThreads.contains("mt"))); + expect(bool(cc->featureRequiresAbiThreads.at("mt"))); // The feature-requires-abi table registers the feature UNCONDITIONALLY // (the same rule feature-deps follows, #359), so it does not trip the // unknown-feature diagnostic on a build that never selects this target. - EXPECT_TRUE(m->featuresMap.contains("mt")); -} + expect(m->featuresMap.contains("mt")); +}}; -TEST(RequiresAbiOnTargetAxis, UnknownMemberInsideRequiresAbiNamesBoth) { +UTest unknown_member_inside_requires_abi_names_both{"RequiresAbiOnTargetAxis/UnknownMemberInsideRequiresAbiNamesBoth", [] { constexpr auto src = R"( [package] name = "adapter" @@ -240,13 +240,12 @@ version = "0.1.0" requires_abi = { thread = true } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find( - "requires_abi.thread: the members are `threads`, `exceptions`, booleans"), - std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find( + "requires_abi.thread: the members are `threads`, `exceptions`, booleans"), std::string::npos)); !failed) failed << m.error().message; +}}; -TEST(RequiresAbiOnTargetAxis, UnknownMemberInsideFeatureFormNamesBoth) { +UTest unknown_member_inside_feature_form_names_both{"RequiresAbiOnTargetAxis/UnknownMemberInsideFeatureFormNamesBoth", [] { constexpr auto src = R"( [package] name = "adapter" @@ -255,12 +254,11 @@ version = "0.1.0" mt = { thread = true } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find( + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find( "feature-requires-abi.mt].thread: the members are `threads`, " - "`exceptions`, booleans"), - std::string::npos) << m.error().message; -} + "`exceptions`, booleans"), std::string::npos)); !failed) failed << m.error().message; +}}; // ─── The union: a matching selector's requirement joins the package's ────── // @@ -273,7 +271,7 @@ namespace manifest_abi { mcpp::manifest::Manifest merged_for(std::string_view src, std::string_view triple) { auto m = mcpp::manifest::parse_string(src); - EXPECT_TRUE(m.has_value()) << (m.has_value() ? "" : m.error().format()); + if (auto&& failed = expect(m.has_value()); !failed) failed << (m.has_value() ? "" : m.error().format()); if (!m) return {}; mcpp::build::merge_conditional_config(*m, cfgpred::context_for(triple)); return *m; @@ -281,7 +279,7 @@ mcpp::manifest::Manifest merged_for(std::string_view src, std::string_view tripl } // namespace manifest_abi -TEST(RequiresAbiOnTargetAxis, MatchingSelectorUnionsIntoThePackagesRequirement) { +UTest matching_selector_unions_into_the_packages_requirement{"RequiresAbiOnTargetAxis/MatchingSelectorUnionsIntoThePackagesRequirement", [] { constexpr auto src = R"( [package] name = "adapter" @@ -290,12 +288,12 @@ version = "0.1.0" requires_abi = { threads = true } )"; auto lin = manifest_abi::merged_for(src, "x86_64-unknown-linux-gnu"); - ASSERT_EQ(lin.targetRequiresAbiThreads.size(), 1u); - EXPECT_EQ(lin.targetRequiresAbiThreads.front(), "cfg(linux)"); - EXPECT_TRUE(lin.targetRequiresAbiExceptions.empty()); -} + expect(fatal(eq(lin.targetRequiresAbiThreads.size(), 1u))); + expect(eq(lin.targetRequiresAbiThreads.front(), "cfg(linux)")); + expect(lin.targetRequiresAbiExceptions.empty()); +}}; -TEST(RequiresAbiOnTargetAxis, ASelectorThatDoesNotMatchTheHostImposesNothing) { +UTest a_selector_that_does_not_match_the_host_imposes_nothing{"RequiresAbiOnTargetAxis/ASelectorThatDoesNotMatchTheHostImposesNothing", [] { constexpr auto src = R"( [package] name = "adapter" @@ -308,10 +306,10 @@ requires_abi = { threads = true } // calls out explicitly (A6: "the Web build is unaffected ... in both // directions"). auto lin = manifest_abi::merged_for(src, "x86_64-unknown-linux-gnu"); - EXPECT_TRUE(lin.targetRequiresAbiThreads.empty()); -} + expect(lin.targetRequiresAbiThreads.empty()); +}}; -TEST(RequiresAbiOnTargetAxis, FeatureFormUnionsOnlyForTheMatchingSelector) { +UTest feature_form_unions_only_for_the_matching_selector{"RequiresAbiOnTargetAxis/FeatureFormUnionsOnlyForTheMatchingSelector", [] { constexpr auto src = R"( [package] name = "adapter" @@ -322,7 +320,7 @@ mt = { threads = true } mt = { exceptions = true } )"; auto lin = manifest_abi::merged_for(src, "x86_64-unknown-linux-gnu"); - ASSERT_TRUE(lin.targetFeatureRequiresAbiThreads.contains("mt")); - EXPECT_EQ(lin.targetFeatureRequiresAbiThreads.at("mt").front(), "cfg(linux)"); - EXPECT_FALSE(lin.targetFeatureRequiresAbiExceptions.contains("mt")); -} + expect(fatal(lin.targetFeatureRequiresAbiThreads.contains("mt"))); + expect(eq(lin.targetFeatureRequiresAbiThreads.at("mt").front(), "cfg(linux)")); + expect(!lin.targetFeatureRequiresAbiExceptions.contains("mt")); +}}; diff --git a/tests/unit/test_abi_tag_accel.cpp b/tests/unit/test_abi_tag_accel.cpp index 8cc35dcfd..ed866aa1e 100644 --- a/tests/unit/test_abi_tag_accel.cpp +++ b/tests/unit/test_abi_tag_accel.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pack.abi_tag; +using namespace mcpplibs::mtest; + using mcpp::pack::AbiTag; using mcpp::pack::AccelSet; using mcpp::pack::tag_check; @@ -31,109 +33,109 @@ bool accepts(const AbiTag& published, const AbiTag& current) { // ─── The published side constrains; an absent dimension does not ─────────── -TEST(AbiTagAccel, PublishedWithoutAccelAcceptsAnyRequest) { +UTest published_without_accel_accepts_any_request{"AbiTagAccel/PublishedWithoutAccelAcceptsAnyRequest", [] { // A CPU-only library states nothing about accelerators, so it is usable // everywhere. This is the existing "a shorter tag IS the statement" rule // reaching one dimension further. - EXPECT_TRUE(accepts(cxx(), with({cuda({"sm_86"})}))); -} + expect(bool(accepts(cxx(), with({cuda({"sm_86"})})))); +}}; -TEST(AbiTagAccel, ARequestWithNoAcceleratorIsSatisfiedVacuously) { - EXPECT_TRUE(accepts(with({cuda({"sm_90"})}), cxx())); -} +UTest a_request_with_no_accelerator_is_satisfied_vacuously{"AbiTagAccel/ARequestWithNoAcceleratorIsSatisfiedVacuously", [] { + expect(bool(accepts(with({cuda({"sm_90"})}), cxx()))); +}}; // ─── Membership ──────────────────────────────────────────────────────────── -TEST(AbiTagAccel, ExactArchIsAccepted) { - EXPECT_TRUE(accepts(with({cuda({"sm_80", "sm_90"})}), with({cuda({"sm_90"})}))); -} +UTest exact_arch_is_accepted{"AbiTagAccel/ExactArchIsAccepted", [] { + expect(bool(accepts(with({cuda({"sm_80", "sm_90"})}), with({cuda({"sm_90"})})))); +}}; -TEST(AbiTagAccel, AnArchOutsideTheSetIsRefused) { +UTest an_arch_outside_the_set_is_refused{"AbiTagAccel/AnArchOutsideTheSetIsRefused", [] { auto bad = tag_check(with({cuda({"sm_80", "sm_90"})}), with({cuda({"sm_86"})})); - ASSERT_EQ(bad.size(), 1u); - EXPECT_EQ(bad[0].dimension, "accel"); - EXPECT_NE(bad[0].need.find("sm_80"), std::string::npos); - EXPECT_NE(bad[0].got.find("sm_86"), std::string::npos); -} + expect(fatal(eq(bad.size(), 1u))); + expect(eq(bad[0].dimension, "accel")); + expect(ne(bad[0].need.find("sm_80"), std::string::npos)); + expect(ne(bad[0].got.find("sm_86"), std::string::npos)); +}}; -TEST(AbiTagAccel, ADifferentBackendIsRefused) { +UTest a_different_backend_is_refused{"AbiTagAccel/ADifferentBackendIsRefused", [] { AccelSet rocm{ .backend = "rocm", .version = "6.4", .archs = {"gfx942"} }; auto bad = tag_check(with({rocm}), with({cuda({"sm_90"})})); - ASSERT_EQ(bad.size(), 1u); - EXPECT_NE(bad[0].need.find("rocm"), std::string::npos); -} + expect(fatal(eq(bad.size(), 1u))); + expect(ne(bad[0].need.find("rocm"), std::string::npos)); +}}; // ─── Family targets widen the published side ─────────────────────────────── -TEST(AbiTagAccel, AFamilyTargetCoversItsFamily) { +UTest a_family_target_covers_its_family{"AbiTagAccel/AFamilyTargetCoversItsFamily", [] { // sm_90f is compatible with the same major and an equal-or-higher minor. // This is why publishing a family target rather than one artifact per chip // is what keeps the variant matrix finite. - EXPECT_TRUE (accepts(with({cuda({"sm_90f"})}), with({cuda({"sm_90"})}))); - EXPECT_TRUE (accepts(with({cuda({"sm_90f"})}), with({cuda({"sm_93"})}))); - EXPECT_FALSE(accepts(with({cuda({"sm_90f"})}), with({cuda({"sm_89"})}))); - EXPECT_FALSE(accepts(with({cuda({"sm_90f"})}), with({cuda({"sm_100"})}))); -} + expect(bool(accepts(with({cuda({"sm_90f"})}), with({cuda({"sm_90"})})))); + expect(bool(accepts(with({cuda({"sm_90f"})}), with({cuda({"sm_93"})})))); + expect(!(accepts(with({cuda({"sm_90f"})}), with({cuda({"sm_89"})})))); + expect(!(accepts(with({cuda({"sm_90f"})}), with({cuda({"sm_100"})})))); +}}; -TEST(AbiTagAccel, AnArchitectureSpecificTargetCoversOnlyItself) { - EXPECT_TRUE (accepts(with({cuda({"sm_90a"})}), with({cuda({"sm_90"})}))); - EXPECT_FALSE(accepts(with({cuda({"sm_90a"})}), with({cuda({"sm_93"})}))); -} +UTest an_architecture_specific_target_covers_only_itself{"AbiTagAccel/AnArchitectureSpecificTargetCoversOnlyItself", [] { + expect(bool(accepts(with({cuda({"sm_90a"})}), with({cuda({"sm_90"})})))); + expect(!(accepts(with({cuda({"sm_90a"})}), with({cuda({"sm_93"})})))); +}}; // ─── The PTX floor is the second asymmetric dimension ────────────────────── -TEST(AbiTagAccel, EmbeddedPtxAcceptsAnythingAtOrAboveTheFloor) { +UTest embedded_ptx_accepts_anything_at_or_above_the_floor{"AbiTagAccel/EmbeddedPtxAcceptsAnythingAtOrAboveTheFloor", [] { // NVIDIA's own guidance for a distributed binary is cubins for the known // targets plus PTX for the newest, so that later hardware JITs. Recording // the floor is what lets tag_check say yes to a device the cubin set does // not name, instead of refusing an artifact that genuinely runs. - EXPECT_TRUE (accepts(with({cuda({"sm_80"}, "80")}), with({cuda({"sm_86"})}))); - EXPECT_TRUE (accepts(with({cuda({"sm_80"}, "80")}), with({cuda({"sm_90"})}))); - EXPECT_FALSE(accepts(with({cuda({"sm_86"}, "86")}), with({cuda({"sm_80"})}))); -} + expect(bool(accepts(with({cuda({"sm_80"}, "80")}), with({cuda({"sm_86"})})))); + expect(bool(accepts(with({cuda({"sm_80"}, "80")}), with({cuda({"sm_90"})})))); + expect(!(accepts(with({cuda({"sm_86"}, "86")}), with({cuda({"sm_80"})})))); +}}; -TEST(AbiTagAccel, WithoutPtxThereIsNoForwardCompatibility) { +UTest without_ptx_there_is_no_forward_compatibility{"AbiTagAccel/WithoutPtxThereIsNoForwardCompatibility", [] { // AMD has no PTX equivalent, so an empty floor must not silently widen the // set. Family coverage on the published side is how ROCm gets the same // effect, and that is a different mechanism. - EXPECT_FALSE(accepts(with({cuda({"sm_80"})}), with({cuda({"sm_86"})}))); -} + expect(!(accepts(with({cuda({"sm_80"})}), with({cuda({"sm_86"})})))); +}}; // ─── Toolkit version ─────────────────────────────────────────────────────── -TEST(AbiTagAccel, TheToolkitMajorMustAgree) { +UTest the_toolkit_major_must_agree{"AbiTagAccel/TheToolkitMajorMustAgree", [] { AccelSet built{ .backend = "cuda", .version = "12.8", .archs = {"sm_90"} }; AccelSet want12{ .backend = "cuda", .version = "12.0", .archs = {"sm_90"} }; AccelSet want13{ .backend = "cuda", .version = "13.0", .archs = {"sm_90"} }; // Minor version compatibility is real within a major release family. - EXPECT_TRUE (accepts(with({built}), with({want12}))); - EXPECT_FALSE(accepts(with({built}), with({want13}))); -} + expect(bool(accepts(with({built}), with({want12})))); + expect(!(accepts(with({built}), with({want13})))); +}}; // ─── Several backends in one artifact ────────────────────────────────────── -TEST(AbiTagAccel, OneArtifactCanCarrySeveralBackends) { +UTest one_artifact_can_carry_several_backends{"AbiTagAccel/OneArtifactCanCarrySeveralBackends", [] { AccelSet rocm{ .backend = "rocm", .version = "6.4", .archs = {"gfx942"} }; auto fat = with({cuda({"sm_90f"}), rocm}); - EXPECT_TRUE(accepts(fat, with({cuda({"sm_90"})}))); + expect(bool(accepts(fat, with({cuda({"sm_90"})})))); AccelSet wantRocm{ .backend = "rocm", .version = "6.4", .archs = {"gfx942"} }; - EXPECT_TRUE(accepts(fat, with({wantRocm}))); -} + expect(bool(accepts(fat, with({wantRocm})))); +}}; // ─── The other four dimensions still decide ──────────────────────────────── -TEST(AbiTagAccel, AccelDoesNotMaskTheCxxDimensions) { +UTest accel_does_not_mask_the_cxx_dimensions{"AbiTagAccel/AccelDoesNotMaskTheCxxDimensions", [] { auto published = with({cuda({"sm_90"})}); auto current = with({cuda({"sm_90"})}); current.compiler = "gcc15"; auto bad = tag_check(published, current); - ASSERT_EQ(bad.size(), 1u); - EXPECT_EQ(bad[0].dimension, "compiler"); -} + expect(fatal(eq(bad.size(), 1u))); + expect(eq(bad[0].dimension, "compiler")); +}}; // ─── The wire form round-trips ───────────────────────────────────────────── -TEST(AbiTagAccel, TheWireFormRoundTrips) { +UTest the_wire_form_round_trips{"AbiTagAccel/TheWireFormRoundTrips", [] { std::vector sets{ cuda({"sm_80", "sm_90f"}, "90"), mcpp::pack::AccelSet{ .backend = "rocm", .version = "6.4", @@ -141,24 +143,24 @@ TEST(AbiTagAccel, TheWireFormRoundTrips) { }; auto text = mcpp::pack::accel_str(sets); auto back = mcpp::pack::parse_accel(text); - ASSERT_EQ(back.size(), 2u); - EXPECT_EQ(back[0].backend, "cuda"); - EXPECT_EQ(back[0].version, "12.8"); - EXPECT_EQ(back[0].archs, (std::vector{"sm_80", "sm_90f"})); - EXPECT_EQ(back[0].ptxFloor, "90"); - EXPECT_EQ(back[1].backend, "rocm"); - EXPECT_EQ(back[1].archs, (std::vector{"gfx942", "gfx10-3-generic"})); - EXPECT_TRUE(back[1].ptxFloor.empty()); - EXPECT_EQ(mcpp::pack::accel_str(back), text); -} - -TEST(AbiTagAccel, UnparseableTextMeansNoDeviceCode) { + expect(fatal(eq(back.size(), 2u))); + expect(eq(back[0].backend, "cuda")); + expect(eq(back[0].version, "12.8")); + expect(eq(back[0].archs, (std::vector{"sm_80", "sm_90f"}))); + expect(eq(back[0].ptxFloor, "90")); + expect(eq(back[1].backend, "rocm")); + expect(eq(back[1].archs, (std::vector{"gfx942", "gfx10-3-generic"}))); + expect(back[1].ptxFloor.empty()); + expect(eq(mcpp::pack::accel_str(back), text)); +}}; + +UTest unparseable_text_means_no_device_code{"AbiTagAccel/UnparseableTextMeansNoDeviceCode", [] { // The safe answer, and the same one a descriptor that never mentioned the // dimension gives: an artifact that states nothing constrains nothing. - EXPECT_TRUE(mcpp::pack::parse_accel("").empty()); - EXPECT_TRUE(mcpp::pack::parse_accel("(none)").empty()); - EXPECT_TRUE(mcpp::pack::parse_accel("12.8+{sm_90}").empty()); // no backend -} + expect(mcpp::pack::parse_accel("").empty()); + expect(mcpp::pack::parse_accel("(none)").empty()); + expect(mcpp::pack::parse_accel("12.8+{sm_90}").empty()); // no backend +}}; // ── The grammar is open: backends mcpp has never heard of ────────────────── // @@ -168,100 +170,99 @@ TEST(AbiTagAccel, UnparseableTextMeansNoDeviceCode) { // just as importantly, that an unknown arch shape falls back to equality rather // than to some numeric guess. -TEST(AccelOpenGrammar, VulkanSpirvRoundTrips) { +UTest vulkan_spirv_round_trips{"AccelOpenGrammar/VulkanSpirvRoundTrips", [] { auto a = mcpp::pack::parse_accel("vulkan1.3+{spirv1.6}"); - ASSERT_EQ(a.size(), 1u); - EXPECT_EQ(a[0].backend, "vulkan"); - EXPECT_EQ(a[0].version, "1.3"); - ASSERT_EQ(a[0].archs.size(), 1u); - EXPECT_EQ(a[0].archs[0], "spirv1.6"); - EXPECT_EQ(mcpp::pack::accel_str(a), "vulkan1.3+{spirv1.6}"); -} - -TEST(AccelOpenGrammar, SyclTargetRoundTrips) { + expect(fatal(eq(a.size(), 1u))); + expect(eq(a[0].backend, "vulkan")); + expect(eq(a[0].version, "1.3")); + expect(fatal(eq(a[0].archs.size(), 1u))); + expect(eq(a[0].archs[0], "spirv1.6")); + expect(eq(mcpp::pack::accel_str(a), "vulkan1.3+{spirv1.6}")); +}}; + +UTest sycl_target_round_trips{"AccelOpenGrammar/SyclTargetRoundTrips", [] { auto a = mcpp::pack::parse_accel("sycl2020+{spir64,nvptx64-sm_89}"); - ASSERT_EQ(a.size(), 1u); - EXPECT_EQ(a[0].backend, "sycl"); - ASSERT_EQ(a[0].archs.size(), 2u); - EXPECT_EQ(a[0].archs[0], "spir64"); - EXPECT_EQ(a[0].archs[1], "nvptx64-sm_89"); -} - -TEST(AccelOpenGrammar, AnUnknownArchShapeComparesByEquality) { + expect(fatal(eq(a.size(), 1u))); + expect(eq(a[0].backend, "sycl")); + expect(fatal(eq(a[0].archs.size(), 2u))); + expect(eq(a[0].archs[0], "spir64")); + expect(eq(a[0].archs[1], "nvptx64-sm_89")); +}}; + +UTest an_unknown_arch_shape_compares_by_equality{"AccelOpenGrammar/AnUnknownArchShapeComparesByEquality", [] { // `gfx942` carries no leading number, so there is no ordering to read out // of it. Equality is the only question available and it is the one asked -- // rather than reading `942` out of the middle and inventing a level. auto pub = mcpp::pack::parse_accel("hip6.4+{gfx942}"); auto same = mcpp::pack::parse_accel("hip6.4+{gfx942}"); auto diff = mcpp::pack::parse_accel("hip6.4+{gfx1100}"); - EXPECT_TRUE (mcpp::pack::accel_accepts(pub, same)); - EXPECT_FALSE(mcpp::pack::accel_accepts(pub, diff)); -} + expect(bool(mcpp::pack::accel_accepts(pub, same))); + expect(!mcpp::pack::accel_accepts(pub, diff)); +}}; -TEST(AccelOpenGrammar, FloorHasABackendNeutralSpelling) { +UTest floor_has_a_backend_neutral_spelling{"AccelOpenGrammar/FloorHasABackendNeutralSpelling", [] { // `ptx>=` is CUDA's word and the one already published. `floor>=` says the // same thing without borrowing it, so a backend whose portable form is // SPIR-V does not have to write `ptx`. auto neutral = mcpp::pack::parse_accel("vulkan1.3+{spirv1.6} floor>=1.4"); - ASSERT_EQ(neutral.size(), 1u); - EXPECT_EQ(neutral[0].ptxFloor, "1.4"); + expect(fatal(eq(neutral.size(), 1u))); + expect(eq(neutral[0].ptxFloor, "1.4")); auto cuda = mcpp::pack::parse_accel("cuda12.9+{sm_80} ptx>=80"); - ASSERT_EQ(cuda.size(), 1u); - EXPECT_EQ(cuda[0].ptxFloor, "80"); -} + expect(fatal(eq(cuda.size(), 1u))); + expect(eq(cuda[0].ptxFloor, "80")); +}}; -TEST(AccelOpenGrammar, TheExistingPtxSpellingStillParses) { +UTest the_existing_ptx_spelling_still_parses{"AccelOpenGrammar/TheExistingPtxSpellingStillParses", [] { // The negative control for the change above: adding a second spelling must // not have moved the first. Descriptors published before it exist. auto a = mcpp::pack::parse_accel("cuda12.8+{sm_80,sm_90f} ptx>=90"); - ASSERT_EQ(a.size(), 1u); - EXPECT_EQ(a[0].ptxFloor, "90"); - EXPECT_EQ(mcpp::pack::accel_str(a), "cuda12.8+{sm_80,sm_90f} ptx>=90"); -} + expect(fatal(eq(a.size(), 1u))); + expect(eq(a[0].ptxFloor, "90")); + expect(eq(mcpp::pack::accel_str(a), "cuda12.8+{sm_80,sm_90f} ptx>=90")); +}}; -TEST(AccelRequest, RejectsIncompleteSetsAndUnconsumedInput) { +UTest rejects_incomplete_sets_and_unconsumed_input{"AccelRequest/RejectsIncompleteSetsAndUnconsumedInput", [] { for (auto input : {"sycl+{sm_89", "sycl+{sm_89}garbage", "sycl+sm_89", "sycl+{sm_89,,sm_90}", "sycl+{,sm_89}", "sycl+{sm_89,}", "sycl+{{sm_89}}", "sycl,", ",sycl", "sycl ptx>=", "12.8+{sm_89}"}) { const auto parsed = mcpp::pack::parse_accel_request(input); - ASSERT_FALSE(parsed) << input; - EXPECT_NE(parsed.error().find("byte"), std::string::npos) << input; + expect(fatal(!parsed)) << input; + expect(ne(parsed.error().find("byte"), std::string::npos)) << input; } -} +}}; -TEST(AccelRequest, DuplicateImplementationsCannotChangeCoverageByTheirOrder) { +UTest duplicate_implementations_cannot_change_coverage_by_their_order{"AccelRequest/DuplicateImplementationsCannotChangeCoverageByTheirOrder", [] { for (auto input : {"sycl+{sm_89},sycl+{sm_90}", "sycl+{sm_90},sycl+{sm_89}"}) { const auto parsed = mcpp::pack::parse_accel_request(input); - ASSERT_FALSE(parsed); - EXPECT_NE(parsed.error().find("appears more than once"), std::string::npos); - EXPECT_NE(parsed.error().find("sycl"), std::string::npos); + expect(fatal(!parsed)); + expect(ne(parsed.error().find("appears more than once"), std::string::npos)); + expect(ne(parsed.error().find("sycl"), std::string::npos)); } -} +}}; -TEST(AccelRequest, EquivalentSetsHaveOneCanonicalSpelling) { +UTest equivalent_sets_have_one_canonical_spelling{"AccelRequest/EquivalentSetsHaveOneCanonicalSpelling", [] { const auto a = mcpp::pack::parse_accel_request( "sycl+{spir64,nvptx64-sm_90,nvptx64-sm_89,spir64}, cuda12.9+{sm_90,sm_89} ptx>=90"); const auto b = mcpp::pack::parse_accel_request( "cuda12.9+{sm_89,sm_90} floor>=90, sycl+{nvptx64-sm_89,nvptx64-sm_90,spir64}"); - ASSERT_TRUE(a); - ASSERT_TRUE(b); - EXPECT_EQ(mcpp::pack::accel_str(*a), mcpp::pack::accel_str(*b)); - EXPECT_EQ(mcpp::pack::accel_str(*a), - "cuda12.9+{sm_89,sm_90} ptx>=90, sycl+{nvptx64-sm_89,nvptx64-sm_90,spir64}"); -} - -TEST(AccelRequest, RetainsEmptyAndUnconstrainedFormsAndOpenTargetVocabulary) { - EXPECT_TRUE(mcpp::pack::parse_accel_request("")->empty()); - EXPECT_TRUE(mcpp::pack::parse_accel_request(" (none) ")->empty()); + expect(fatal(bool(a))); + expect(fatal(bool(b))); + expect(eq(mcpp::pack::accel_str(*a), mcpp::pack::accel_str(*b))); + expect(eq(mcpp::pack::accel_str(*a), "cuda12.9+{sm_89,sm_90} ptx>=90, sycl+{nvptx64-sm_89,nvptx64-sm_90,spir64}")); +}}; + +UTest retains_empty_and_unconstrained_forms_and_open_target_vocabulary{"AccelRequest/RetainsEmptyAndUnconstrainedFormsAndOpenTargetVocabulary", [] { + expect(mcpp::pack::parse_accel_request("")->empty()); + expect(mcpp::pack::parse_accel_request(" (none) ")->empty()); const auto bare = mcpp::pack::parse_accel_request("sycl"); - ASSERT_TRUE(bare); - ASSERT_EQ(bare->size(), 1u); - EXPECT_TRUE(bare->front().archs.empty()); + expect(fatal(bool(bare))); + expect(fatal(eq(bare->size(), 1u))); + expect(bare->front().archs.empty()); const auto explicitEmpty = mcpp::pack::parse_accel_request("sycl+{}"); - ASSERT_TRUE(explicitEmpty); - EXPECT_EQ(mcpp::pack::accel_str(*bare), mcpp::pack::accel_str(*explicitEmpty)); + expect(fatal(bool(explicitEmpty))); + expect(eq(mcpp::pack::accel_str(*bare), mcpp::pack::accel_str(*explicitEmpty))); const auto opaque = mcpp::pack::parse_accel_request("newmodel3.1+{future-device-v2,domain:opaque}"); - ASSERT_TRUE(opaque); - ASSERT_EQ(opaque->front().archs.size(), 2u); -} + expect(fatal(bool(opaque))); + expect(fatal(eq(opaque->front().archs.size(), 2u))); +}}; diff --git a/tests/unit/test_action_progress.cpp b/tests/unit/test_action_progress.cpp index a7b72ba41..d600a9183 100644 --- a/tests/unit/test_action_progress.cpp +++ b/tests/unit/test_action_progress.cpp @@ -1,18 +1,20 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.action_progress; + +using namespace mcpplibs::mtest; namespace telemetry = mcpp::build::action_progress; -TEST(ActionProgress, AcceptsUnknownTotalsAndExplicitCounts) { +UTest accepts_unknown_totals_and_explicit_counts{"ActionProgress/AcceptsUnknownTotalsAndExplicitCounts", [] { const auto unknown = telemetry::parse(R"({"v":1,"seq":0,"phase":"configure","detail":"compiler checks"})"); - ASSERT_TRUE(unknown); - EXPECT_FALSE(unknown->total); - EXPECT_EQ(telemetry::describe(*unknown), "configure · compiler checks"); + expect(fatal(bool(unknown))); + expect(!unknown->total); + expect(eq(telemetry::describe(*unknown), "configure · compiler checks")); const auto known = telemetry::parse(R"({"v":1,"seq":7,"phase":"compile","done":3,"total":9})"); - ASSERT_TRUE(known); - EXPECT_EQ(telemetry::describe(*known), "compile 3/9"); -} -TEST(ActionProgress, RejectsInvalidSnapshotsWithoutInferringSuccess) { + expect(fatal(bool(known))); + expect(eq(telemetry::describe(*known), "compile 3/9")); +}}; +UTest rejects_invalid_snapshots_without_inferring_success{"ActionProgress/RejectsInvalidSnapshotsWithoutInferringSuccess", [] { for (const auto text : { R"({"v":2,"seq":1,"phase":"compile"})", R"({"v":1,"seq":-1,"phase":"compile"})", @@ -22,23 +24,23 @@ TEST(ActionProgress, RejectsInvalidSnapshotsWithoutInferringSuccess) { R"({"v":1,"seq":1,"phase":"compile\nforged"})", R"({"v":1,"seq":1,"phase":"compile","detail":"\u001b[31m"})", R"({"v":1,"seq":1,"phase":"compile")"}) - EXPECT_FALSE(telemetry::parse(text)) << text; - EXPECT_FALSE(telemetry::parse(std::string(telemetry::kMaxBytes + 1, ' '))); + expect(!telemetry::parse(text)) << text; + expect(!(telemetry::parse(std::string(telemetry::kMaxBytes + 1, ' ')))); // Finishing internal work is data, not a successful command or a stamp. - EXPECT_TRUE(telemetry::parse(R"({"v":1,"seq":2,"phase":"link","done":1,"total":1})")); -} -TEST(ActionProgress, AllocatesIndependentRunDirectoriesAndBoundsReads) { + expect(bool(telemetry::parse(R"({"v":1,"seq":2,"phase":"link","done":1,"total":1})"))); +}}; +UTest allocates_independent_run_directories_and_bounds_reads{"ActionProgress/AllocatesIndependentRunDirectoriesAndBoundsReads", [] { const auto base = std::filesystem::temp_directory_path(); const auto first = telemetry::make_directory(base); const auto second = telemetry::make_directory(base); - ASSERT_FALSE(first.empty()); ASSERT_FALSE(second.empty()); - EXPECT_NE(first, second); + expect(fatal(!first.empty())); expect(fatal(!second.empty())); + expect(ne(first, second)); const auto a = telemetry::channel(first, "obj/a.o"); - EXPECT_NE(a, telemetry::channel(first, "obj/b.o")); - EXPECT_NE(a, telemetry::channel(second, "obj/a.o")); + expect(ne(a, telemetry::channel(first, "obj/b.o"))); + expect(ne(a, telemetry::channel(second, "obj/a.o"))); std::ofstream(a) << std::string(telemetry::kMaxBytes + 1, ' '); - EXPECT_FALSE(telemetry::read(a)); + expect(!telemetry::read(a)); std::ofstream(a) << R"({"v":1,"seq":0,"phase":"work"})"; - EXPECT_TRUE(telemetry::read(a)); + expect(bool(telemetry::read(a))); std::filesystem::remove_all(first); std::filesystem::remove_all(second); -} +}}; diff --git a/tests/unit/test_artifact_naming.cpp b/tests/unit/test_artifact_naming.cpp index e14b539a0..88420e48b 100644 --- a/tests/unit/test_artifact_naming.cpp +++ b/tests/unit/test_artifact_naming.cpp @@ -1,10 +1,12 @@ -#include import std; +import mcpplibs.mtest; import mcpplibs.os.platform; import mcpp.toolchain.platform; import mcpp.toolchain.triple; +using namespace mcpplibs::mtest; + // How a built artifact is NAMED is a property of the TARGET, never of the // machine doing the build. // @@ -36,127 +38,127 @@ tr::Triple T(std::string_view s) { // ── The regression: naming must not come from the build host ──────────────── -TEST(ArtifactNaming, LinuxTargetIgnoresHostAnswer) { +UTest linux_target_ignores_host_answer{"ArtifactNaming/LinuxTargetIgnoresHostAnswer", [] { auto n = tr::artifact_naming(T("x86_64-linux-musl"), kBogusHost); - EXPECT_EQ(n.exeSuffix, ""); - EXPECT_EQ(n.libPrefix, "lib"); - EXPECT_EQ(n.staticLibExt, ".a"); - EXPECT_EQ(n.sharedLibExt, ".so"); - EXPECT_FALSE(n.sharedNeedsImportLib); -} - -TEST(ArtifactNaming, MacosTargetIgnoresHostAnswer) { + expect(eq(n.exeSuffix, "")); + expect(eq(n.libPrefix, "lib")); + expect(eq(n.staticLibExt, ".a")); + expect(eq(n.sharedLibExt, ".so")); + expect(!n.sharedNeedsImportLib); +}}; + +UTest macos_target_ignores_host_answer{"ArtifactNaming/MacosTargetIgnoresHostAnswer", [] { auto n = tr::artifact_naming(T("aarch64-macos"), kBogusHost); - EXPECT_EQ(n.exeSuffix, ""); - EXPECT_EQ(n.libPrefix, "lib"); - EXPECT_EQ(n.staticLibExt, ".a"); - EXPECT_EQ(n.sharedLibExt, ".dylib"); - EXPECT_FALSE(n.sharedNeedsImportLib); -} + expect(eq(n.exeSuffix, "")); + expect(eq(n.libPrefix, "lib")); + expect(eq(n.staticLibExt, ".a")); + expect(eq(n.sharedLibExt, ".dylib")); + expect(!n.sharedNeedsImportLib); +}}; // ── The (os, env) split — the part a single _WIN32 branch cannot express ───── // // windows-gnu uses the GNU convention. mcpp names this `foo.lib` today even on // a Windows host, so mingw's `ar` emits a GNU archive wearing an MSVC name. // That is a pre-existing defect, independent of cross compilation. -TEST(ArtifactNaming, WindowsGnuUsesGnuConvention) { +UTest windows_gnu_uses_gnu_convention{"ArtifactNaming/WindowsGnuUsesGnuConvention", [] { auto n = tr::artifact_naming(T("x86_64-windows-gnu"), kBogusHost); - EXPECT_EQ(n.exeSuffix, ".exe"); - EXPECT_EQ(n.libPrefix, "lib"); // NOT "" - EXPECT_EQ(n.staticLibExt, ".a"); // NOT ".lib" - EXPECT_EQ(n.sharedLibExt, ".dll"); - EXPECT_TRUE(n.sharedNeedsImportLib); -} - -TEST(ArtifactNaming, WindowsMsvcUsesMsvcConvention) { + expect(eq(n.exeSuffix, ".exe")); + expect(eq(n.libPrefix, "lib")); // NOT "" + expect(eq(n.staticLibExt, ".a")); // NOT ".lib" + expect(eq(n.sharedLibExt, ".dll")); + expect(bool(n.sharedNeedsImportLib)); +}}; + +UTest windows_msvc_uses_msvc_convention{"ArtifactNaming/WindowsMsvcUsesMsvcConvention", [] { auto n = tr::artifact_naming(T("x86_64-windows-msvc"), kBogusHost); - EXPECT_EQ(n.exeSuffix, ".exe"); - EXPECT_EQ(n.libPrefix, ""); - EXPECT_EQ(n.staticLibExt, ".lib"); - EXPECT_EQ(n.sharedLibExt, ".dll"); - EXPECT_TRUE(n.sharedNeedsImportLib); -} + expect(eq(n.exeSuffix, ".exe")); + expect(eq(n.libPrefix, "")); + expect(eq(n.staticLibExt, ".lib")); + expect(eq(n.sharedLibExt, ".dll")); + expect(bool(n.sharedNeedsImportLib)); +}}; // The legacy mingw spelling must resolve identically to the canonical one — // the triple parser is the single source of truth, not a substring match. -TEST(ArtifactNaming, LegacyMingwSpellingMatchesCanonical) { +UTest legacy_mingw_spelling_matches_canonical{"ArtifactNaming/LegacyMingwSpellingMatchesCanonical", [] { auto legacy = tr::artifact_naming(T("x86_64-w64-mingw32"), kBogusHost); auto canonical = tr::artifact_naming(T("x86_64-windows-gnu"), kBogusHost); - EXPECT_EQ(legacy.exeSuffix, canonical.exeSuffix); - EXPECT_EQ(legacy.libPrefix, canonical.libPrefix); - EXPECT_EQ(legacy.staticLibExt, canonical.staticLibExt); - EXPECT_EQ(legacy.sharedLibExt, canonical.sharedLibExt); -} + expect(eq(legacy.exeSuffix, canonical.exeSuffix)); + expect(eq(legacy.libPrefix, canonical.libPrefix)); + expect(eq(legacy.staticLibExt, canonical.staticLibExt)); + expect(eq(legacy.sharedLibExt, canonical.sharedLibExt)); +}}; // ── Host target: the one case where the host answer IS correct ────────────── -TEST(ArtifactNaming, EmptyTripleFallsBackToHost) { +UTest empty_triple_falls_back_to_host{"ArtifactNaming/EmptyTripleFallsBackToHost", [] { auto n = tr::artifact_naming(tr::Triple{}, kBogusHost); - EXPECT_EQ(n.exeSuffix, ".HOST"); - EXPECT_EQ(n.libPrefix, "HOST"); -} + expect(eq(n.exeSuffix, ".HOST")); + expect(eq(n.libPrefix, "HOST")); +}}; // An unparseable triple must not be guessed at — a wrong guess here silently // misnames every artifact of the build. -TEST(ArtifactNaming, UnknownOsFallsBackToHost) { +UTest unknown_os_falls_back_to_host{"ArtifactNaming/UnknownOsFallsBackToHost", [] { tr::Triple wasm; wasm.arch = "wasm32"; wasm.os = "unknown"; auto n = tr::artifact_naming(wasm, kBogusHost); - EXPECT_EQ(n.exeSuffix, ".HOST"); - EXPECT_EQ(n.staticLibExt, ".HOST"); -} + expect(eq(n.exeSuffix, ".HOST")); + expect(eq(n.staticLibExt, ".HOST")); +}}; // A bare-metal `none` OS is not the emscripten row and must keep falling back // to the host answer — the emscripten branch below must not widen the // fallback it sits next to. -TEST(ArtifactNaming, NoneOsFallsBackToHost) { +UTest none_os_falls_back_to_host{"ArtifactNaming/NoneOsFallsBackToHost", [] { tr::Triple none; none.arch = "aarch64"; none.os = "none"; auto n = tr::artifact_naming(none, kBogusHost); - EXPECT_EQ(n.exeSuffix, ".HOST"); - EXPECT_EQ(n.staticLibExt, ".HOST"); -} + expect(eq(n.exeSuffix, ".HOST")); + expect(eq(n.staticLibExt, ".HOST")); +}}; // ── wasm32-emscripten: the executable is the file a runner executes, and on // this row that file is the JavaScript launcher, not a bare ELF-shaped name. // See .agents/docs/2026-09-12-622-a-ui-framework-on-android-ios-and-web.md §2.5. // A Linux host must not leak its own (empty-suffix) naming onto the row. -TEST(ArtifactNaming, EmscriptenOnLinuxHostNamesJs) { +UTest emscripten_on_linux_host_names_js{"ArtifactNaming/EmscriptenOnLinuxHostNamesJs", [] { const tr::ArtifactNaming linuxHost{ .exeSuffix = "", .libPrefix = "lib", .staticLibExt = ".a", .sharedLibExt = ".so", .sharedNeedsImportLib = false, }; auto n = tr::artifact_naming(T("wasm32-emscripten"), linuxHost); - EXPECT_EQ(n.exeSuffix, ".js"); - EXPECT_EQ(n.libPrefix, "lib"); - EXPECT_EQ(n.staticLibExt, ".a"); -} + expect(eq(n.exeSuffix, ".js")); + expect(eq(n.libPrefix, "lib")); + expect(eq(n.staticLibExt, ".a")); +}}; // A Windows host must not leak `.exe` onto the row either — the row names one // file on every host. -TEST(ArtifactNaming, EmscriptenOnWindowsHostNamesJs) { +UTest emscripten_on_windows_host_names_js{"ArtifactNaming/EmscriptenOnWindowsHostNamesJs", [] { const tr::ArtifactNaming windowsHost{ .exeSuffix = ".exe", .libPrefix = "", .staticLibExt = ".lib", .sharedLibExt = ".dll", .sharedNeedsImportLib = true, }; auto n = tr::artifact_naming(T("wasm32-emscripten"), windowsHost); - EXPECT_EQ(n.exeSuffix, ".js"); - EXPECT_EQ(n.libPrefix, "lib"); - EXPECT_EQ(n.staticLibExt, ".a"); -} + expect(eq(n.exeSuffix, ".js")); + expect(eq(n.libPrefix, "lib")); + expect(eq(n.staticLibExt, ".a")); +}}; // A side module needs `-sSIDE_MODULE`, which mcpp does not render — the row // marks shared libraries unsupported rather than naming a file emcc's default // link would not actually produce as a working shared object. -TEST(ArtifactNaming, EmscriptenSharedIsUnsupported) { +UTest emscripten_shared_is_unsupported{"ArtifactNaming/EmscriptenSharedIsUnsupported", [] { auto n = tr::artifact_naming(T("wasm32-emscripten"), kBogusHost); - EXPECT_TRUE(n.sharedNeedsImportLib); - EXPECT_EQ(n.sharedLibExt, ""); -} + expect(bool(n.sharedNeedsImportLib)); + expect(eq(n.sharedLibExt, "")); +}}; // ── Host builds must be bit-for-bit unchanged ─────────────────────────────── // Passing the real host constants for the host target has to reproduce exactly // what the old code produced on this machine. -TEST(ArtifactNaming, RealHostConstantsRoundTrip) { +UTest real_host_constants_round_trip{"ArtifactNaming/RealHostConstantsRoundTrip", [] { const tr::ArtifactNaming host{ .exeSuffix = mcpplibs::os::exe_suffix, .libPrefix = mcpp::toolchain::platform::lib_prefix, @@ -165,11 +167,11 @@ TEST(ArtifactNaming, RealHostConstantsRoundTrip) { .sharedNeedsImportLib = mcpplibs::os::is_windows, }; auto n = tr::artifact_naming(tr::Triple{}, host); - EXPECT_EQ(n.exeSuffix, mcpplibs::os::exe_suffix); - EXPECT_EQ(n.libPrefix, mcpp::toolchain::platform::lib_prefix); - EXPECT_EQ(n.staticLibExt, mcpp::toolchain::platform::static_lib_ext); - EXPECT_EQ(n.sharedLibExt, mcpp::toolchain::platform::shared_lib_ext); -} + expect(eq(n.exeSuffix, mcpplibs::os::exe_suffix)); + expect(eq(n.libPrefix, mcpp::toolchain::platform::lib_prefix)); + expect(eq(n.staticLibExt, mcpp::toolchain::platform::static_lib_ext)); + expect(eq(n.sharedLibExt, mcpp::toolchain::platform::shared_lib_ext)); +}}; // ── #622 A3: the link form of `kind = "app"` is a property of the row ─────── // @@ -180,44 +182,38 @@ TEST(ArtifactNaming, RealHostConstantsRoundTrip) { // `bin`. The negative direction here is the positive direction of every OTHER // row — a form function with only one answer would not be a function. -TEST(ArtifactNaming, ApplicationFormIsSharedObjectOnAndroid) { - EXPECT_EQ(tr::application_form(T("aarch64-linux-android")), - tr::ApplicationForm::SharedObject); - EXPECT_EQ(tr::application_form(T("x86_64-linux-android")), - tr::ApplicationForm::SharedObject); -} +UTest application_form_is_shared_object_on_android{"ArtifactNaming/ApplicationFormIsSharedObjectOnAndroid", [] { + expect(eq(tr::application_form(T("aarch64-linux-android")), tr::ApplicationForm::SharedObject)); + expect(eq(tr::application_form(T("x86_64-linux-android")), tr::ApplicationForm::SharedObject)); +}}; -TEST(ArtifactNaming, ApplicationFormIsExecutableEverywhereElse) { - EXPECT_EQ(tr::application_form(T("x86_64-linux-gnu")), - tr::ApplicationForm::Executable); - EXPECT_EQ(tr::application_form(T("aarch64-ios-sim")), - tr::ApplicationForm::Executable); - EXPECT_EQ(tr::application_form(T("wasm32-emscripten")), - tr::ApplicationForm::Executable); - EXPECT_EQ(tr::application_form(T("x86_64-windows-msvc")), - tr::ApplicationForm::Executable); -} +UTest application_form_is_executable_everywhere_else{"ArtifactNaming/ApplicationFormIsExecutableEverywhereElse", [] { + expect(eq(tr::application_form(T("x86_64-linux-gnu")), tr::ApplicationForm::Executable)); + expect(eq(tr::application_form(T("aarch64-ios-sim")), tr::ApplicationForm::Executable)); + expect(eq(tr::application_form(T("wasm32-emscripten")), tr::ApplicationForm::Executable)); + expect(eq(tr::application_form(T("x86_64-windows-msvc")), tr::ApplicationForm::Executable)); +}}; // An empty (host) triple is not Android on any machine this engine runs on // today -- the negative direction that keeps a bare `kind = "app"` host build // linking an ordinary executable, exactly as it does before this feature. -TEST(ArtifactNaming, ApplicationFormOnTheHostTripleIsExecutable) { - EXPECT_EQ(tr::application_form(tr::Triple{}), tr::ApplicationForm::Executable); -} +UTest application_form_on_the_host_triple_is_executable{"ArtifactNaming/ApplicationFormOnTheHostTripleIsExecutable", [] { + expect(eq(tr::application_form(tr::Triple{}), tr::ApplicationForm::Executable)); +}}; // ── #630 A9: the Android ABI name of a row's architecture ─────────────────── // // The two rows mcpp currently produces a shared object for; a third // architecture keeps its GNU spelling rather than a guessed Android name. -TEST(AndroidAbi, TranslatesTheTwoKnownArchitectures) { - EXPECT_EQ(tr::android_abi(T("aarch64-linux-android")), "arm64-v8a"); - EXPECT_EQ(tr::android_abi(T("x86_64-linux-android")), "x86_64"); -} +UTest translates_the_two_known_architectures{"AndroidAbi/TranslatesTheTwoKnownArchitectures", [] { + expect(eq(tr::android_abi(T("aarch64-linux-android")), "arm64-v8a")); + expect(eq(tr::android_abi(T("x86_64-linux-android")), "x86_64")); +}}; -TEST(AndroidAbi, AnUntranslatedArchitectureKeepsItsGnuSpelling) { - EXPECT_EQ(tr::android_abi(T("riscv64-linux-android")), "riscv64"); -} +UTest an_untranslated_architecture_keeps_its_gnu_spelling{"AndroidAbi/AnUntranslatedArchitectureKeepsItsGnuSpelling", [] { + expect(eq(tr::android_abi(T("riscv64-linux-android")), "riscv64")); +}}; } // namespace @@ -225,27 +221,27 @@ TEST(AndroidAbi, AnUntranslatedArchitectureKeepsItsGnuSpelling) { // platform of its own wins. Every row the engine has maps into the closed // vocabulary `[package] platforms` accepts, so a claim can be made for every // row and for nothing else. -TEST(PlatformName, IsTheOsExceptForAndroid) { +UTest is_the_os_except_for_android{"PlatformName/IsTheOsExceptForAndroid", [] { using mcpp::toolchain::triple::parse; using mcpp::toolchain::triple::platform_name; - EXPECT_EQ(platform_name(*parse("x86_64-linux-gnu")), "linux"); - EXPECT_EQ(platform_name(*parse("aarch64-macos")), "macos"); - EXPECT_EQ(platform_name(*parse("x86_64-windows-msvc")), "windows"); - EXPECT_EQ(platform_name(*parse("aarch64-ios-sim")), "ios"); - EXPECT_EQ(platform_name(*parse("aarch64-linux-android")), "android"); - EXPECT_EQ(platform_name(*parse("wasm32-emscripten")), "emscripten"); -} - -TEST(PlatformName, EveryKnownRowIsInTheVocabulary) { + expect(eq(platform_name(*parse("x86_64-linux-gnu")), "linux")); + expect(eq(platform_name(*parse("aarch64-macos")), "macos")); + expect(eq(platform_name(*parse("x86_64-windows-msvc")), "windows")); + expect(eq(platform_name(*parse("aarch64-ios-sim")), "ios")); + expect(eq(platform_name(*parse("aarch64-linux-android")), "android")); + expect(eq(platform_name(*parse("wasm32-emscripten")), "emscripten")); +}}; + +UTest every_known_row_is_in_the_vocabulary{"PlatformName/EveryKnownRowIsInTheVocabulary", [] { using namespace mcpp::toolchain::triple; for (auto const& row : known_targets()) { auto t = parse(std::string(row.canonical)); - ASSERT_TRUE(t.has_value()) << row.canonical; + expect(fatal(t.has_value())) << row.canonical; if (t->os == "none") continue; // bare-metal rows claim no platform - EXPECT_TRUE(is_platform_name(platform_name(*t))) << row.canonical; + expect(is_platform_name(platform_name(*t))) << row.canonical; } - EXPECT_TRUE(is_platform_name("emscripten")); - EXPECT_FALSE(is_platform_name("web")); - EXPECT_TRUE(is_platform_name("openkal")); - EXPECT_EQ(platform_names_joined(), "linux | macos | windows | ios | android | emscripten | openkal"); -} + expect(is_platform_name("emscripten")); + expect(!is_platform_name("web")); + expect(is_platform_name("openkal")); + expect(eq(platform_names_joined(), "linux | macos | windows | ios | android | emscripten | openkal")); +}}; diff --git a/tests/unit/test_bmi_cache.cpp b/tests/unit/test_bmi_cache.cpp index 4214e4814..2970585e9 100644 --- a/tests/unit/test_bmi_cache.cpp +++ b/tests/unit/test_bmi_cache.cpp @@ -1,4 +1,3 @@ -#include #include #if !defined(_WIN32) #include @@ -6,6 +5,7 @@ #endif import std; +import mcpplibs.mtest; import mcpp.bmi_cache; import mcpp.bmi_cache.maintenance; import mcpp.home; @@ -15,19 +15,21 @@ import mcpplibs.os.env; import mcpplibs.os.platform; import mcpplibs.os.windows; +using namespace mcpplibs::mtest; + using namespace mcpp::bmi_cache; -TEST(BMICacheMaintenance, RunnerCachePathDoesNotUseTheWindowsAnsiCodePage) { +UTest runner_cache_path_does_not_use_the_windows_ansi_code_page{"BMICacheMaintenance/RunnerCachePathDoesNotUseTheWindowsAnsiCodePage", [] { if constexpr (!mcpplibs::os::is_windows) { - GTEST_SKIP() << "path::string() only converts through an ANSI code page on Windows"; + skip("path::string() only converts through an ANSI code page on Windows"); } else { const auto acp = mcpplibs::os::windows::code_page(); // GetACP, including a UTF-8 process manifest. - if (acp == 65001) GTEST_SKIP() << "UTF-8 ACP can represent every path"; + if (acp == 65001) skip("UTF-8 ACP can represent every path"); const std::filesystem::path name(std::wstring{wchar_t(0x0915), wchar_t(0x0916), wchar_t(0x0917)}); bool narrowing_throws = false; try { (void)name.string(); } catch (const std::exception&) { narrowing_throws = true; } - ASSERT_TRUE(narrowing_throws) << "the fixture must be unrepresentable in ACP " << acp; + expect(fatal(bool(narrowing_throws))) << "the fixture must be unrepresentable in ACP " << acp; const auto scratch = std::filesystem::temp_directory_path() / std::format("mcpp-runner-cache-{}", std::random_device{}()); @@ -42,12 +44,12 @@ TEST(BMICacheMaintenance, RunnerCachePathDoesNotUseTheWindowsAnsiCodePage) { // to find these names: gc still reports that outcome without throwing // while preparing the native executable path or its diagnostic. const auto where = mcpplibs::os::env::find_executable("where.exe"); - ASSERT_TRUE(where.has_value()); + expect(fatal(where.has_value())); std::filesystem::copy_file(*where, bin / "okrun.exe"); mcpp::invocation::ScopedEnv selected("MCPP_HOME", mcpp::home::spelling(home)); - EXPECT_NO_THROW({ EXPECT_EQ(cache_gc("", "3650d"), 0); }); + expect(nothrow([&] { { expect(eq(cache_gc("", "3650d"), 0)); }; })); } -} +}}; namespace { @@ -121,32 +123,32 @@ void seedProject(const std::filesystem::path& project) { } // namespace -TEST(BmiCache, EntryDirIsKeyedByPackageIdentityAndKeyNotByProject) { +UTest entry_dir_is_keyed_by_package_identity_and_key_not_by_project{"BmiCache/EntryDirIsKeyedByPackageIdentityAndKeyNotByProject", [] { auto k = makeKey("/home/u/.mcpp/build-cache/v1"); auto expected = std::filesystem::path( "/home/u/.mcpp/build-cache/v1/pkg/mcpplibs/mcpplibs.cmdline@0.0.1/deadbeef0123abcd"); - EXPECT_EQ(k.dir(), expected); - EXPECT_EQ(k.entryFile().filename().string(), "entry.json"); - EXPECT_EQ(k.bmiDir().filename().string(), "bmi"); - EXPECT_EQ(k.objDir().filename().string(), "obj"); -} + expect(eq(k.dir(), expected)); + expect(eq(k.entryFile().filename().string(), "entry.json")); + expect(eq(k.bmiDir().filename().string(), "bmi")); + expect(eq(k.objDir().filename().string(), "obj")); +}}; // The layout version must be a path segment: replacing the layout has to be a // new tree, never a migration of (or a deletion of) the old one. -TEST(BmiCache, EntryDirCarriesTheLayoutVersion) { +UTest entry_dir_carries_the_layout_version{"BmiCache/EntryDirCarriesTheLayoutVersion", [] { auto k = makeKey("/home/u/.mcpp/build-cache/v1"); auto s = k.dir().generic_string(); - EXPECT_NE(s.find("/build-cache/v1/pkg/"), std::string::npos) << s; + expect(ne(s.find("/build-cache/v1/pkg/"), std::string::npos)) << s; // And nothing may land under the pre-v1 root. - EXPECT_EQ(s.find("/.mcpp/bmi/"), std::string::npos) << s; -} + expect(eq(s.find("/.mcpp/bmi/"), std::string::npos)) << s; +}}; -TEST(BmiCache, IsCachedFalseWhenEntryMissing) { +UTest is_cached_false_when_entry_missing{"BmiCache/IsCachedFalseWhenEntryMissing", [] { Tmp t; - EXPECT_FALSE(is_cached(makeKey(t.path), oneOfEach())); -} + expect(!(is_cached(makeKey(t.path), oneOfEach()))); +}}; -TEST(BmiCache, PopulateWritesSelfDescribingEntry) { +UTest populate_writes_self_describing_entry{"BmiCache/PopulateWritesSelfDescribingEntry", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; @@ -154,106 +156,106 @@ TEST(BmiCache, PopulateWritesSelfDescribingEntry) { auto k = makeKey(home); auto pop = populate_from(k, project, oneOfEach()); - ASSERT_TRUE(pop) << pop.error(); + if (auto&& failed = expect(fatal(bool(pop))); !failed) failed << pop.error(); - ASSERT_TRUE(std::filesystem::exists(k.entryFile())); - EXPECT_TRUE(std::filesystem::exists(k.bmiDir() / "lib.gcm")); - EXPECT_TRUE(std::filesystem::exists(k.objDir() / "lib.m.o")); - EXPECT_TRUE(is_cached(k, oneOfEach())); + expect(fatal(std::filesystem::exists(k.entryFile()))); + expect(std::filesystem::exists(k.bmiDir() / "lib.gcm")); + expect(std::filesystem::exists(k.objDir() / "lib.m.o")); + expect(is_cached(k, oneOfEach())); // The entry has to carry the key AND the full inputs: a hit is validated // field by field, so a cache whose entries only listed files could never be // audited when a wrong hit was suspected. auto j = readJson(k.entryFile()); - EXPECT_EQ(j.value("key", std::string{}), "deadbeef0123abcd"); - EXPECT_EQ(j["inputs"], makeInputs()); - EXPECT_EQ(j["bmi"].size(), 1u); - EXPECT_EQ(j["obj"].size(), 1u); - EXPECT_TRUE(j.contains("created")); - EXPECT_TRUE(j.contains("accessed")); -} + expect(eq(j.value("key", std::string{}), "deadbeef0123abcd")); + expect(eq(j["inputs"], makeInputs())); + expect(eq(j["bmi"].size(), 1u)); + expect(eq(j["obj"].size(), 1u)); + expect(j.contains("created")); + expect(j.contains("accessed")); +}}; // The whole point of recording inputs: equal hashes are never trusted on their // own. Same key directory, different inputs ⇒ miss. -TEST(BmiCache, IsCachedFalseWhenRecordedInputsDiffer) { +UTest is_cached_false_when_recorded_inputs_differ{"BmiCache/IsCachedFalseWhenRecordedInputsDiffer", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; seedProject(project); auto written = makeKey(home, "samekey00000000", "base"); - ASSERT_TRUE(populate_from(written, project, oneOfEach())); - EXPECT_TRUE(is_cached(written, oneOfEach())); + expect(fatal(bool(populate_from(written, project, oneOfEach())))); + expect(is_cached(written, oneOfEach())); auto probed = makeKey(home, "samekey00000000", "different-flag"); - EXPECT_FALSE(is_cached(probed, oneOfEach())); -} + expect(!(is_cached(probed, oneOfEach()))); +}}; // An entry written by an older mcpp may carry extra keys, but every field the // current build cares about must be present. Missing ⇒ mismatch, never a pass. -TEST(BmiCache, IsCachedFalseWhenARequiredInputFieldIsAbsent) { +UTest is_cached_false_when_a_required_input_field_is_absent{"BmiCache/IsCachedFalseWhenARequiredInputFieldIsAbsent", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; seedProject(project); auto k = makeKey(home); - ASSERT_TRUE(populate_from(k, project, oneOfEach())); + expect(fatal(bool(populate_from(k, project, oneOfEach())))); auto j = readJson(k.entryFile()); j["inputs"].erase("profile"); std::ofstream(k.entryFile()) << j.dump(2); - EXPECT_FALSE(is_cached(k, oneOfEach())); -} + expect(!(is_cached(k, oneOfEach()))); +}}; -TEST(BmiCache, IsCachedFalseWhenSchemaDiffers) { +UTest is_cached_false_when_schema_differs{"BmiCache/IsCachedFalseWhenSchemaDiffers", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; seedProject(project); auto k = makeKey(home); - ASSERT_TRUE(populate_from(k, project, oneOfEach())); + expect(fatal(bool(populate_from(k, project, oneOfEach())))); auto j = readJson(k.entryFile()); j["schema"] = kEntrySchema + 1; std::ofstream(k.entryFile()) << j.dump(2); - EXPECT_FALSE(is_cached(k, oneOfEach())); -} + expect(!(is_cached(k, oneOfEach()))); +}}; -TEST(BmiCache, IsCachedFalseWhenSentinelExistsButFileMissing) { +UTest is_cached_false_when_sentinel_exists_but_file_missing{"BmiCache/IsCachedFalseWhenSentinelExistsButFileMissing", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; seedProject(project); auto k = makeKey(home); - ASSERT_TRUE(populate_from(k, project, oneOfEach())); - ASSERT_TRUE(is_cached(k, oneOfEach())); + expect(fatal(bool(populate_from(k, project, oneOfEach())))); + expect(fatal(is_cached(k, oneOfEach()))); std::filesystem::remove(k.objDir() / "lib.m.o"); - EXPECT_FALSE(is_cached(k, oneOfEach())); -} + expect(!(is_cached(k, oneOfEach()))); +}}; -TEST(BmiCache, ResolveCachedReportsTheArtifactsWithoutCopying) { +UTest resolve_cached_reports_the_artifacts_without_copying{"BmiCache/ResolveCachedReportsTheArtifactsWithoutCopying", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; seedProject(project); auto k = makeKey(home); - ASSERT_TRUE(populate_from(k, project, oneOfEach())); + expect(fatal(bool(populate_from(k, project, oneOfEach())))); auto arts = resolve_cached(k); - ASSERT_TRUE(arts) << arts.error(); - EXPECT_EQ(arts->bmiFiles, std::vector{"lib.gcm"}); - ASSERT_EQ(arts->objFiles.size(), 1u); - EXPECT_EQ(arts->objFiles[0].cacheRel, "lib.m.o"); + if (auto&& failed = expect(fatal(bool(arts))); !failed) failed << arts.error(); + expect(eq(arts->bmiFiles, std::vector{"lib.gcm"})); + expect(fatal(eq(arts->objFiles.size(), 1u))); + expect(eq(arts->objFiles[0].cacheRel, "lib.m.o")); // Read-back never yields a build path: the entry does not record one, which // is the whole point of mcpp#344. - EXPECT_TRUE(arts->objFiles[0].buildRel.empty()); + expect(arts->objFiles[0].buildRel.empty()); // resolve_cached must not write into a project dir. Staging is a ninja edge // now: copying artifacts in from outside the graph is exactly what made the @@ -261,17 +263,17 @@ TEST(BmiCache, ResolveCachedReportsTheArtifactsWithoutCopying) { // record for, so the copies were recompiled over on every fresh build dir. auto project2 = t.path / "proj2" / "target"; (void)resolve_cached(k); - EXPECT_FALSE(std::filesystem::exists(project2)); -} + expect(!std::filesystem::exists(project2)); +}}; -TEST(BmiCache, CachedPathsPointIntoTheEntry) { +UTest cached_paths_point_into_the_entry{"BmiCache/CachedPathsPointIntoTheEntry", [] { auto k = makeKey("/home/u/.mcpp/build-cache/v1"); - EXPECT_EQ(cached_bmi_path(k, "lib.gcm"), k.bmiDir() / "lib.gcm"); - EXPECT_EQ(cached_obj_path(k, "sub/lib.m.o"), k.objDir() / "sub" / "lib.m.o"); -} + expect(eq(cached_bmi_path(k, "lib.gcm"), k.bmiDir() / "lib.gcm")); + expect(eq(cached_obj_path(k, "sub/lib.m.o"), k.objDir() / "sub" / "lib.m.o")); +}}; // Nested object paths (mcpp#233's per-package object prefixes) must round-trip. -TEST(BmiCache, PopulateHandlesNestedObjectPaths) { +UTest populate_handles_nested_object_paths{"BmiCache/PopulateHandlesNestedObjectPaths", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; @@ -280,14 +282,14 @@ TEST(BmiCache, PopulateHandlesNestedObjectPaths) { auto k = makeKey(home); DepArtifacts arts { .objFiles = {{"zlib-1.3/compress.o", "obj/pkg_zlib/zlib-1.3/compress.o"}} }; - ASSERT_TRUE(populate_from(k, project, arts)); + expect(fatal(bool(populate_from(k, project, arts)))); // Stored at the ENTRY address, read from the BUILD path. The entry knows // nothing about the `pkg_zlib/` partition, which is consumer-side. - EXPECT_EQ(readFile(k.objDir() / "zlib-1.3" / "compress.o"), "NESTED"); - EXPECT_TRUE(is_cached(k, arts)); + expect(eq(readFile(k.objDir() / "zlib-1.3" / "compress.o"), "NESTED")); + expect(is_cached(k, arts)); auto j = readJson(k.entryFile()); - EXPECT_EQ(j["obj"][0].get(), "zlib-1.3/compress.o"); -} + expect(eq(j["obj"][0].get(), "zlib-1.3/compress.o")); +}}; // mcpp#344, the load-bearing case. Two consumers of ONE key may place the same // object at different build-dir paths (#233's basename disambiguation fires on @@ -296,7 +298,7 @@ TEST(BmiCache, PopulateHandlesNestedObjectPaths) { // get a clean MISS, with the divergence named — never a hit that goes on to // stage a file the entry never had, which is what ninja reported as // "missing and no known rule to make it" at graph load. -TEST(BmiCache, ProbeMissesWhenTheEntryDoesNotHoldTheRequestedArtifacts) { +UTest probe_misses_when_the_entry_does_not_hold_the_requested_artifacts{"BmiCache/ProbeMissesWhenTheEntryDoesNotHoldTheRequestedArtifacts", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; @@ -306,63 +308,63 @@ TEST(BmiCache, ProbeMissesWhenTheEntryDoesNotHoldTheRequestedArtifacts) { // The first consumer populated the entry under a DIFFERENT internal // address than the one this build is going to ask for. DepArtifacts written { .objFiles = {{"other/compress.o", "obj/compress.o"}} }; - ASSERT_TRUE(populate_from(k, project, written)); + expect(fatal(bool(populate_from(k, project, written)))); DepArtifacts wanted { .objFiles = {{"zlib-1.3/compress.o", "obj/compress.o"}} }; auto probe = probe_cached(k, wanted); - EXPECT_FALSE(probe.ok); - ASSERT_EQ(probe.layoutMismatch.size(), 1u); - EXPECT_EQ(probe.layoutMismatch[0], "zlib-1.3/compress.o"); -} + expect(!probe.ok); + expect(fatal(eq(probe.layoutMismatch.size(), 1u))); + expect(eq(probe.layoutMismatch[0], "zlib-1.3/compress.o")); +}}; // The same asymmetry on the read side: an entry that lists a file it does not // have on disk must not be reported as a layout divergence OR as a hit. -TEST(BmiCache, ProbeMissesWhenARequestedFileIsListedButAbsent) { +UTest probe_misses_when_a_requested_file_is_listed_but_absent{"BmiCache/ProbeMissesWhenARequestedFileIsListedButAbsent", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; seedProject(project); auto k = makeKey(home); - ASSERT_TRUE(populate_from(k, project, oneOfEach())); + expect(fatal(bool(populate_from(k, project, oneOfEach())))); std::filesystem::remove(k.objDir() / "lib.m.o"); auto probe = probe_cached(k, oneOfEach()); - EXPECT_FALSE(probe.ok); - ASSERT_EQ(probe.layoutMismatch.size(), 1u); - EXPECT_EQ(probe.layoutMismatch[0], "lib.m.o"); -} + expect(!probe.ok); + expect(fatal(eq(probe.layoutMismatch.size(), 1u))); + expect(eq(probe.layoutMismatch[0], "lib.m.o")); +}}; // An entry whose own validation fails (wrong inputs) is an ordinary miss and // carries NO layout complaint — the caller warns on layout divergence only, and // warning on every routine miss would make the signal worthless. -TEST(BmiCache, ProbeReportsNoLayoutMismatchWhenTheEntryItselfIsInvalid) { +UTest probe_reports_no_layout_mismatch_when_the_entry_itself_is_invalid{"BmiCache/ProbeReportsNoLayoutMismatchWhenTheEntryItselfIsInvalid", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; seedProject(project); - ASSERT_TRUE(populate_from(makeKey(home, "samekey00000000", "base"), - project, oneOfEach())); + expect(fatal(bool(populate_from(makeKey(home, "samekey00000000", "base"), + project, oneOfEach())))); auto probe = probe_cached(makeKey(home, "samekey00000000", "other"), oneOfEach()); - EXPECT_FALSE(probe.ok); - EXPECT_TRUE(probe.layoutMismatch.empty()); -} + expect(!probe.ok); + expect(probe.layoutMismatch.empty()); +}}; // touch_accessed is what makes `cache gc` an LRU rather than "drop what was // populated long ago". A use sets the time of the entry's record (mcpp::home:: // mark_used, design 2026-10-31 §4.6): the record's content and the artifacts // stay as they are -- the artifacts' mtimes participate in ninja's restat // handling -- and a second use within 12 hours writes nothing. -TEST(BmiCache, TouchAccessedMovesTheStampAndNotTheArtifacts) { +UTest touch_accessed_moves_the_stamp_and_not_the_artifacts{"BmiCache/TouchAccessedMovesTheStampAndNotTheArtifacts", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; seedProject(project); auto k = makeKey(home); - ASSERT_TRUE(populate_from(k, project, oneOfEach())); + expect(fatal(bool(populate_from(k, project, oneOfEach())))); const auto content0 = readJson(k.entryFile()).dump(); auto objTime0 = std::filesystem::last_write_time(k.objDir() / "lib.m.o"); @@ -374,36 +376,36 @@ TEST(BmiCache, TouchAccessedMovesTheStampAndNotTheArtifacts) { touch_accessed(k); const auto used = std::filesystem::last_write_time(k.entryFile()); - EXPECT_GT(used, old + std::chrono::hours(47)) << "a use must move the record's time"; - EXPECT_EQ(readJson(k.entryFile()).dump(), content0) << "a use must not rewrite the record"; - EXPECT_EQ(std::filesystem::last_write_time(k.objDir() / "lib.m.o"), objTime0); - EXPECT_EQ(std::filesystem::last_write_time(k.bmiDir() / "lib.gcm"), bmiTime0); - EXPECT_TRUE(is_cached(k, oneOfEach())) << "touching must not invalidate the entry"; + expect(gt(used, old + std::chrono::hours(47))) << "a use must move the record's time"; + expect(eq(readJson(k.entryFile()).dump(), content0)) << "a use must not rewrite the record"; + expect(eq(std::filesystem::last_write_time(k.objDir() / "lib.m.o"), objTime0)); + expect(eq(std::filesystem::last_write_time(k.bmiDir() / "lib.gcm"), bmiTime0)); + expect(is_cached(k, oneOfEach())) << "touching must not invalidate the entry"; // Within 12 hours a second use writes nothing. const auto recent = std::filesystem::file_time_type::clock::now() - std::chrono::hours(1); std::filesystem::last_write_time(k.entryFile(), recent); touch_accessed(k); - EXPECT_EQ(std::filesystem::last_write_time(k.entryFile()), recent); -} + expect(eq(std::filesystem::last_write_time(k.entryFile()), recent)); +}}; -TEST(BmiCache, RepopulatePreservesCreatedStamp) { +UTest repopulate_preserves_created_stamp{"BmiCache/RepopulatePreservesCreatedStamp", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; seedProject(project); auto k = makeKey(home); - ASSERT_TRUE(populate_from(k, project, oneOfEach())); + expect(fatal(bool(populate_from(k, project, oneOfEach())))); auto j0 = readJson(k.entryFile()); j0["created"] = "12345"; std::ofstream(k.entryFile()) << j0.dump(2); - ASSERT_TRUE(populate_from(k, project, oneOfEach())); - EXPECT_EQ(readJson(k.entryFile()).value("created", std::string{}), "12345"); -} + expect(fatal(bool(populate_from(k, project, oneOfEach())))); + expect(eq(readJson(k.entryFile()).value("created", std::string{}), "12345")); +}}; -TEST(BmiCache, PopulateFailsIfBuildOutputMissing) { +UTest populate_fails_if_build_output_missing{"BmiCache/PopulateFailsIfBuildOutputMissing", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; @@ -411,14 +413,14 @@ TEST(BmiCache, PopulateFailsIfBuildOutputMissing) { DepArtifacts arts { .bmiFiles = {"missing.gcm"} }; auto k = makeKey(home); auto pop = populate_from(k, project, arts); - EXPECT_FALSE(pop); - EXPECT_NE(pop.error().find("expected build output missing"), std::string::npos); -} + expect(!pop); + expect(ne(pop.error().find("expected build output missing"), std::string::npos)); +}}; // Two keys for the same package@version are independent entries — that is what // lets one machine hold a dev-profile and a release-profile build of the same // dependency at once. -TEST(BmiCache, DifferentKeysAreIndependentEntries) { +UTest different_keys_are_independent_entries{"BmiCache/DifferentKeysAreIndependentEntries", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; @@ -426,17 +428,17 @@ TEST(BmiCache, DifferentKeysAreIndependentEntries) { auto a = makeKey(home, "aaaaaaaaaaaaaaaa", "opt"); auto b = makeKey(home, "bbbbbbbbbbbbbbbb", "debug"); - ASSERT_TRUE(populate_from(a, project, oneOfEach())); - ASSERT_TRUE(populate_from(b, project, oneOfEach())); - EXPECT_NE(a.dir(), b.dir()); - EXPECT_TRUE(is_cached(a, oneOfEach())); - EXPECT_TRUE(is_cached(b, oneOfEach())); -} + expect(fatal(bool(populate_from(a, project, oneOfEach())))); + expect(fatal(bool(populate_from(b, project, oneOfEach())))); + expect(ne(a.dir(), b.dir())); + expect(is_cached(a, oneOfEach())); + expect(is_cached(b, oneOfEach())); +}}; #if !defined(_WIN32) // When an external holder takes the .lock, populate_from must skip (returns // success but does NOT clobber the entry). Uses flock(), POSIX-only. -TEST(BmiCache, PopulateSkipsWhenLockHeld) { +UTest populate_skips_when_lock_held{"BmiCache/PopulateSkipsWhenLockHeld", [] { Tmp t; auto home = t.path / "home"; auto project = t.path / "proj" / "target"; @@ -446,18 +448,18 @@ TEST(BmiCache, PopulateSkipsWhenLockHeld) { std::filesystem::create_directories(k.dir()); auto lockPath = k.dir() / ".lock"; int fd = ::open(lockPath.c_str(), O_CREAT | O_RDWR, 0644); - ASSERT_GE(fd, 0); - ASSERT_EQ(::flock(fd, LOCK_EX | LOCK_NB), 0); + expect(fatal(ge(fd, 0))); + expect(fatal(eq(::flock(fd, LOCK_EX | LOCK_NB), 0))); auto pop = populate_from(k, project, oneOfEach()); - EXPECT_TRUE(pop) << "should silently skip when lock is held"; - EXPECT_FALSE(std::filesystem::exists(k.entryFile())); + expect(bool(pop)) << "should silently skip when lock is held"; + expect(!(std::filesystem::exists(k.entryFile()))); ::flock(fd, LOCK_UN); ::close(fd); auto pop2 = populate_from(k, project, oneOfEach()); - ASSERT_TRUE(pop2) << pop2.error(); - EXPECT_TRUE(std::filesystem::exists(k.entryFile())); -} + if (auto&& failed = expect(fatal(bool(pop2))); !failed) failed << pop2.error(); + expect(std::filesystem::exists(k.entryFile())); +}}; #endif // !defined(_WIN32) diff --git a/tests/unit/test_bmi_equivalent.cpp b/tests/unit/test_bmi_equivalent.cpp index 0b8ffc8fe..01bd847b2 100644 --- a/tests/unit/test_bmi_equivalent.cpp +++ b/tests/unit/test_bmi_equivalent.cpp @@ -5,19 +5,23 @@ // would pass every "equivalent BMIs compare equal" test while silently // suppressing every legitimate cascade — which is a far worse bug than the one // it replaces. Half of these cases exist to catch that. -#include #include #include #include +import mcpplibs.mtest; +import mcpplibs.os.process; + import mcpp.build.stage; +using namespace mcpplibs::mtest; + namespace { std::filesystem::path tmpdir() { auto d = std::filesystem::temp_directory_path() / - ("mcpp-bmi-eq-" + std::to_string(::getpid())); + ("mcpp-bmi-eq-" + std::to_string(mcpplibs::os::process::self()->id)); std::filesystem::create_directories(d); return d; } @@ -38,73 +42,73 @@ std::string bmi_like(const std::string& stamp, const std::string& payload = "PAY } // namespace -TEST(BmiEquivalent, IdenticalFilesAreEquivalent) { +UTest identical_files_are_equivalent{"BmiEquivalent/IdenticalFilesAreEquivalent", [] { const auto d = tmpdir(); auto a = write(d / "a.gcm", bmi_like("2026/08/12 02:25:01 UTC")); auto b = write(d / "b.gcm", bmi_like("2026/08/12 02:25:01 UTC")); - EXPECT_TRUE(mcpp::build::stage::bmi_equivalent(a, b)); -} + expect(bool(mcpp::build::stage::bmi_equivalent(a, b))); +}}; // The whole reason this function exists: GCC stamps a wall clock into the BMI, // so two compiles of identical source differ by a few bytes and `cmp` reports // "changed" every single time. -TEST(BmiEquivalent, DifferingOnlyInTheEmbeddedTimestamp) { +UTest differing_only_in_the_embedded_timestamp{"BmiEquivalent/DifferingOnlyInTheEmbeddedTimestamp", [] { const auto d = tmpdir(); auto a = write(d / "t1.gcm", bmi_like("2026/08/12 02:25:01 UTC")); auto b = write(d / "t2.gcm", bmi_like("2026/08/12 02:25:33 UTC")); - EXPECT_TRUE(mcpp::build::stage::bmi_equivalent(a, b)); -} + expect(bool(mcpp::build::stage::bmi_equivalent(a, b))); +}}; // The side that must NOT be lost: a real interface change still cascades. -TEST(BmiEquivalent, DifferingPayloadIsNotEquivalent) { +UTest differing_payload_is_not_equivalent{"BmiEquivalent/DifferingPayloadIsNotEquivalent", [] { const auto d = tmpdir(); auto a = write(d / "p1.gcm", bmi_like("2026/08/12 02:25:01 UTC", "PAYLOAD")); auto b = write(d / "p2.gcm", bmi_like("2026/08/12 02:25:01 UTC", "PAYLOAX")); - EXPECT_FALSE(mcpp::build::stage::bmi_equivalent(a, b)); -} + expect(!mcpp::build::stage::bmi_equivalent(a, b)); +}}; // A payload difference must not be masked just because a timestamp is nearby. -TEST(BmiEquivalent, PayloadDifferenceIsNotHiddenByATimestampDifference) { +UTest payload_difference_is_not_hidden_by_a_timestamp_difference{"BmiEquivalent/PayloadDifferenceIsNotHiddenByATimestampDifference", [] { const auto d = tmpdir(); auto a = write(d / "m1.gcm", bmi_like("2026/08/12 02:25:01 UTC", "PAYLOAD")); auto b = write(d / "m2.gcm", bmi_like("2026/08/12 09:59:59 UTC", "PAYLOAX")); - EXPECT_FALSE(mcpp::build::stage::bmi_equivalent(a, b)); -} + expect(!mcpp::build::stage::bmi_equivalent(a, b)); +}}; -TEST(BmiEquivalent, DifferentSizesAreNeverEquivalent) { +UTest different_sizes_are_never_equivalent{"BmiEquivalent/DifferentSizesAreNeverEquivalent", [] { const auto d = tmpdir(); auto a = write(d / "s1.gcm", bmi_like("2026/08/12 02:25:01 UTC")); auto b = write(d / "s2.gcm", bmi_like("2026/08/12 02:25:01 UTC") + "EXTRA"); - EXPECT_FALSE(mcpp::build::stage::bmi_equivalent(a, b)); -} + expect(!mcpp::build::stage::bmi_equivalent(a, b)); +}}; // No stamps at all → strict comparison, which is the old behaviour. This is the // conservative fallback that keeps the function from ever being MORE permissive // than a byte compare on inputs it does not understand. -TEST(BmiEquivalent, WithoutStampsItIsAStrictCompare) { +UTest without_stamps_it_is_a_strict_compare{"BmiEquivalent/WithoutStampsItIsAStrictCompare", [] { const auto d = tmpdir(); auto a = write(d / "n1.gcm", "no stamps here"); auto b = write(d / "n2.gcm", "no stamps here"); auto c = write(d / "n3.gcm", "no stamps HERE"); - EXPECT_TRUE(mcpp::build::stage::bmi_equivalent(a, b)); - EXPECT_FALSE(mcpp::build::stage::bmi_equivalent(a, c)); -} + expect(bool(mcpp::build::stage::bmi_equivalent(a, b))); + expect(!mcpp::build::stage::bmi_equivalent(a, c)); +}}; // A value that merely follows the prefix but is not a timestamp must not be // masked — otherwise an attacker-shaped or simply unusual BMI could hide a real // difference behind the literal text "buildtime: ". -TEST(BmiEquivalent, NonTimestampAfterThePrefixIsNotMasked) { +UTest non_timestamp_after_the_prefix_is_not_masked{"BmiEquivalent/NonTimestampAfterThePrefixIsNotMasked", [] { const auto d = tmpdir(); auto a = write(d / "f1.gcm", "buildtime: not-a-timestamp-xxxxxA"); auto b = write(d / "f2.gcm", "buildtime: not-a-timestamp-xxxxxB"); - EXPECT_FALSE(mcpp::build::stage::bmi_equivalent(a, b)); -} + expect(!mcpp::build::stage::bmi_equivalent(a, b)); +}}; -TEST(BmiEquivalent, MissingFileIsNotEquivalent) { +UTest missing_file_is_not_equivalent{"BmiEquivalent/MissingFileIsNotEquivalent", [] { const auto d = tmpdir(); auto a = write(d / "e1.gcm", bmi_like("2026/08/12 02:25:01 UTC")); - EXPECT_FALSE(mcpp::build::stage::bmi_equivalent(a, d / "does-not-exist.gcm")); -} + expect(!mcpp::build::stage::bmi_equivalent(a, d / "does-not-exist.gcm")); +}}; // A real BMI carries exactly two stamps — one `buildtime:`, one `localtime:` — // checked across BMIs from 10 KiB to 645 KiB. A third one did not come from @@ -114,12 +118,12 @@ TEST(BmiEquivalent, MissingFileIsNotEquivalent) { // Asserted from BOTH sides. Checking only that the extra span is not masked // would also pass an implementation that masks nothing at all, which is the // bug this whole function exists to fix. -TEST(BmiEquivalent, MoreStampsThanGccEmitsFallsBackToStrictCompare) { +UTest more_stamps_than_gcc_emits_falls_back_to_strict_compare{"BmiEquivalent/MoreStampsThanGccEmitsFallsBackToStrictCompare", [] { const auto d = tmpdir(); // Two stamps: masked, so the differing clock does not count. auto two_a = write(d / "t1.gcm", bmi_like("2026/08/12 02:25:01 UTC")); auto two_b = write(d / "t2.gcm", bmi_like("2026/08/12 02:25:09 UTC")); - EXPECT_TRUE(mcpp::build::stage::bmi_equivalent(two_a, two_b)); + expect(bool(mcpp::build::stage::bmi_equivalent(two_a, two_b))); // The same two, plus a third stamp-shaped string in the payload that // differs. It must NOT be masked, so the two files are different. @@ -129,5 +133,5 @@ TEST(BmiEquivalent, MoreStampsThanGccEmitsFallsBackToStrictCompare) { auto three_b = write(d / "u2.gcm", bmi_like("2026/08/12 02:25:09 UTC", "buildtime: 2020/01/02 00:00:00 UTC")); - EXPECT_FALSE(mcpp::build::stage::bmi_equivalent(three_a, three_b)); -} + expect(!mcpp::build::stage::bmi_equivalent(three_a, three_b)); +}}; diff --git a/tests/unit/test_build_cache_record.cpp b/tests/unit/test_build_cache_record.cpp index 3727d66c1..3bf54c6e9 100644 --- a/tests/unit/test_build_cache_record.cpp +++ b/tests/unit/test_build_cache_record.cpp @@ -1,10 +1,12 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.execute; import mcpp.build.prepare; import mcpp.build.graph_shape; +using namespace mcpplibs::mtest; + using namespace mcpp::build; // The record of a build (`target/.build_cache`) and the one predicate every fast @@ -52,41 +54,41 @@ BuildCacheEntry minimal_entry() { // ─── the engine ───────────────────────────────────────────────────────────── -TEST(EngineIdentity, TheRunningEngineIsNotDeclined) { +UTest the_running_engine_is_not_declined{"EngineIdentity/TheRunningEngineIsNotDeclined", [] { const auto engine = running_engine(); - EXPECT_FALSE(engine.version.empty()); - EXPECT_FALSE(engine.exe.empty()); - EXPECT_FALSE(engine_declined_because(engine, engine).has_value()); -} + expect(!engine.version.empty()); + expect(!engine.exe.empty()); + expect(!(engine_declined_because(engine, engine).has_value())); +}}; -TEST(EngineIdentity, AnEntryWithoutTheFieldDeclinesAndSaysWhy) { +UTest an_entry_without_the_field_declines_and_says_why{"EngineIdentity/AnEntryWithoutTheFieldDeclinesAndSaysWhy", [] { const auto why = engine_declined_because(std::nullopt, running_engine()); - ASSERT_TRUE(why.has_value()); - EXPECT_NE(why->find("predates the engine identity"), std::string::npos) << *why; -} + expect(fatal(why.has_value())); + if (auto&& failed = expect(ne(why->find("predates the engine identity"), std::string::npos)); !failed) failed << *why; +}}; -TEST(EngineIdentity, AnotherVersionDeclinesAndNamesBoth) { +UTest another_version_declines_and_names_both{"EngineIdentity/AnotherVersionDeclinesAndNamesBoth", [] { const auto running = running_engine(); auto recorded = running; recorded.version = "2026.1.1.1"; const auto why = engine_declined_because(recorded, running); - ASSERT_TRUE(why.has_value()); - EXPECT_NE(why->find("2026.1.1.1"), std::string::npos) << *why; - EXPECT_NE(why->find(running.version), std::string::npos) << *why; -} + expect(fatal(why.has_value())); + if (auto&& failed = expect(ne(why->find("2026.1.1.1"), std::string::npos)); !failed) failed << *why; + if (auto&& failed = expect(ne(why->find(running.version), std::string::npos)); !failed) failed << *why; +}}; // The path alone is enough. A binary moved to another directory, or reinstalled // there, is the same version and leaves the same stale text in the graph that a // new version does: the graph names the executable by absolute path. -TEST(EngineIdentity, AnotherPathAtTheSameVersionDeclinesAndNamesBoth) { +UTest another_path_at_the_same_version_declines_and_names_both{"EngineIdentity/AnotherPathAtTheSameVersionDeclinesAndNamesBoth", [] { const auto running = running_engine(); auto recorded = running; recorded.exe = "/removed/install/bin/mcpp"; const auto why = engine_declined_because(recorded, running); - ASSERT_TRUE(why.has_value()); - EXPECT_NE(why->find("/removed/install/bin/mcpp"), std::string::npos) << *why; - EXPECT_NE(why->find(running.exe), std::string::npos) << *why; -} + expect(fatal(why.has_value())); + if (auto&& failed = expect(ne(why->find("/removed/install/bin/mcpp"), std::string::npos)); !failed) failed << *why; + if (auto&& failed = expect(ne(why->find(running.exe), std::string::npos)); !failed) failed << *why; +}}; // ─── the admission predicate ──────────────────────────────────────────────── @@ -94,37 +96,36 @@ TEST(EngineIdentity, AnotherPathAtTheSameVersionDeclinesAndNamesBoth) { // else about the record is read, so the reason is the engine's whatever else is // wrong with the entry. This is what makes the one predicate cover an upgrade // for the three fast paths that call it. -TEST(AdmitRecordedBuild, AnotherEngineIsDeclinedBeforeAnythingElseIsRead) { +UTest another_engine_is_declined_before_anything_else_is_read{"AdmitRecordedBuild/AnotherEngineIsDeclinedBeforeAnythingElseIsRead", [] { auto e = minimal_entry(); e.engine = running_engine(); e.engine->version = "2026.1.1.1"; const auto admitted = admit_recorded_build(e, ReplayAsk{.manifest = "mcpp.toml"}); - ASSERT_FALSE(admitted.has_value()); - EXPECT_NE(admitted.error().find("2026.1.1.1"), std::string::npos) << admitted.error(); -} + expect(fatal(!admitted.has_value())); + if (auto&& failed = expect(ne(admitted.error().find("2026.1.1.1"), std::string::npos)); !failed) failed << admitted.error(); +}}; -TEST(AdmitRecordedBuild, AnEntryWithoutTheEngineIsDeclined) { +UTest an_entry_without_the_engine_is_declined{"AdmitRecordedBuild/AnEntryWithoutTheEngineIsDeclined", [] { auto e = minimal_entry(); const auto admitted = admit_recorded_build(e, ReplayAsk{.manifest = "mcpp.toml"}); - ASSERT_FALSE(admitted.has_value()); - EXPECT_NE(admitted.error().find("predates the engine identity"), std::string::npos) - << admitted.error(); -} + expect(fatal(!admitted.has_value())); + if (auto&& failed = expect(ne(admitted.error().find("predates the engine identity"), std::string::npos)); !failed) failed << admitted.error(); +}}; // With the engine matching, the question moves on to the entry's other fields: // the decline is no longer the engine's. -TEST(AdmitRecordedBuild, TheSameEngineIsAskedTheNextQuestion) { +UTest the_same_engine_is_asked_the_next_question{"AdmitRecordedBuild/TheSameEngineIsAskedTheNextQuestion", [] { auto e = minimal_entry(); e.engine = running_engine(); const auto admitted = admit_recorded_build(e, ReplayAsk{.manifest = "mcpp.toml"}); - ASSERT_FALSE(admitted.has_value()); - EXPECT_EQ(admitted.error().find("engine"), std::string::npos) << admitted.error(); - EXPECT_NE(admitted.error().find("runtime binding"), std::string::npos) << admitted.error(); -} + expect(fatal(!admitted.has_value())); + if (auto&& failed = expect(eq(admitted.error().find("engine"), std::string::npos)); !failed) failed << admitted.error(); + if (auto&& failed = expect(ne(admitted.error().find("runtime binding"), std::string::npos)); !failed) failed << admitted.error(); +}}; // ─── the record, written and read ─────────────────────────────────────────── -TEST(BuildCacheRecord, TheEngineAndEachRootsTablesSurviveAWriteAndARead) { +UTest the_engine_and_each_roots_tables_survive_a_write_and_a_read{"BuildCacheRecord/TheEngineAndEachRootsTablesSurviveAWriteAndARead", [] { Tmp tmp; auto e = minimal_entry(); e.engine = EngineIdentity{"2026.10.2.1", "/opt/with space/bin/mcpp"}; @@ -139,20 +140,20 @@ TEST(BuildCacheRecord, TheEngineAndEachRootsTablesSurviveAWriteAndARead) { write_build_cache_entries(tmp.path / "target" / ".build_cache", {e}); const auto read = read_build_cache(tmp.path); - ASSERT_EQ(read.size(), 1u); - ASSERT_TRUE(read[0].engine.has_value()); - EXPECT_EQ(*read[0].engine, *e.engine); - EXPECT_TRUE(read[0].depSourceRootsRecorded); - EXPECT_EQ(read[0].depSourceRoots, e.depSourceRoots); + expect(fatal(eq(read.size(), 1u))); + expect(fatal(read[0].engine.has_value())); + expect(eq(*read[0].engine, *e.engine)); + expect(bool(read[0].depSourceRootsRecorded)); + expect(eq(read[0].depSourceRoots, e.depSourceRoots)); // The fields written after the two above were read too: the blocks do not // swallow one another. - EXPECT_TRUE(read[0].toolchainRecorded); - EXPECT_EQ(read[0].toolchainRequest, e.toolchainRequest); -} + expect(bool(read[0].toolchainRecorded)); + expect(eq(read[0].toolchainRequest, e.toolchainRequest)); +}}; // A path the line format cannot hold is not written at all, so the entry reads // as one that predates the list and declines, rather than as a shorter path. -TEST(BuildCacheRecord, ARootWithATabInItsPathIsLeftUnrecorded) { +UTest a_root_with_a_tab_in_its_path_is_left_unrecorded{"BuildCacheRecord/ARootWithATabInItsPathIsLeftUnrecorded", [] { Tmp tmp; auto e = minimal_entry(); e.depSourceRoots = {{"/work/a\tb", {".ixx"}, {}}}; @@ -161,28 +162,28 @@ TEST(BuildCacheRecord, ARootWithATabInItsPathIsLeftUnrecorded) { e.toolchainRequest = "cli=;default=gcc@16.1.0"; write_build_cache_entries(tmp.path / "target" / ".build_cache", {e}); const auto read = read_build_cache(tmp.path); - ASSERT_EQ(read.size(), 1u); - EXPECT_FALSE(read[0].depSourceRootsRecorded); - EXPECT_TRUE(read[0].depSourceRoots.empty()); - EXPECT_TRUE(read[0].toolchainRecorded); -} + expect(fatal(eq(read.size(), 1u))); + expect(!(read[0].depSourceRootsRecorded)); + expect(read[0].depSourceRoots.empty()); + expect(bool(read[0].toolchainRecorded)); +}}; -TEST(BuildCacheRecord, AnEmptyListOfRootsIsRecordedNotAbsent) { +UTest an_empty_list_of_roots_is_recorded_not_absent{"BuildCacheRecord/AnEmptyListOfRootsIsRecordedNotAbsent", [] { Tmp tmp; auto e = minimal_entry(); e.depSourceRootsRecorded = true; write_build_cache_entries(tmp.path / "target" / ".build_cache", {e}); const auto read = read_build_cache(tmp.path); - ASSERT_EQ(read.size(), 1u); - EXPECT_TRUE(read[0].depSourceRootsRecorded); - EXPECT_TRUE(read[0].depSourceRoots.empty()); - EXPECT_FALSE(read[0].engine.has_value()); -} + expect(fatal(eq(read.size(), 1u))); + expect(bool(read[0].depSourceRootsRecorded)); + expect(read[0].depSourceRoots.empty()); + expect(!(read[0].engine.has_value())); +}}; // An engine that recorded the paths alone wrote `depSourceRoots=`. Its roots // have no tables, so the block is read past and left unrecorded, and the fields // after it still parse. -TEST(BuildCacheRecord, TheOlderBlockOfPathsIsReadPastAndUnrecorded) { +UTest the_older_block_of_paths_is_read_past_and_unrecorded{"BuildCacheRecord/TheOlderBlockOfPathsIsReadPastAndUnrecorded", [] { Tmp tmp; write_file(tmp.path / "target" / ".build_cache", "[target=]\n" @@ -206,19 +207,19 @@ TEST(BuildCacheRecord, TheOlderBlockOfPathsIsReadPastAndUnrecorded) { "toolchain=cli=;default=gcc@16.1.0\n" "xlingsPayloads=0\n"); const auto read = read_build_cache(tmp.path); - ASSERT_EQ(read.size(), 1u); - EXPECT_FALSE(read[0].depSourceRootsRecorded); - EXPECT_TRUE(read[0].depSourceRoots.empty()); - EXPECT_FALSE(read[0].engine.has_value()); - EXPECT_EQ(read[0].profile, "dev"); - EXPECT_TRUE(read[0].toolchainRecorded); - EXPECT_EQ(read[0].toolchainRequest, "cli=;default=gcc@16.1.0"); - EXPECT_TRUE(read[0].xlingsPayloadsRecorded); -} + expect(fatal(eq(read.size(), 1u))); + expect(!(read[0].depSourceRootsRecorded)); + expect(read[0].depSourceRoots.empty()); + expect(!(read[0].engine.has_value())); + expect(eq(read[0].profile, "dev")); + expect(bool(read[0].toolchainRecorded)); + expect(eq(read[0].toolchainRequest, "cli=;default=gcc@16.1.0")); + expect(bool(read[0].xlingsPayloadsRecorded)); +}}; // A line of the block that is not a path and two lists leaves the block // unrecorded rather than read with a table nobody wrote. -TEST(BuildCacheRecord, AMalformedRootLineLeavesTheBlockUnrecorded) { +UTest a_malformed_root_line_leaves_the_block_unrecorded{"BuildCacheRecord/AMalformedRootLineLeavesTheBlockUnrecorded", [] { Tmp tmp; write_file(tmp.path / "target" / ".build_cache", "[target=]\n" @@ -231,9 +232,9 @@ TEST(BuildCacheRecord, AMalformedRootLineLeavesTheBlockUnrecorded) { "/work/rules\n" "runner=0\n"); const auto read = read_build_cache(tmp.path); - ASSERT_EQ(read.size(), 1u); - EXPECT_FALSE(read[0].depSourceRootsRecorded); -} + expect(fatal(eq(read.size(), 1u))); + expect(!(read[0].depSourceRootsRecorded)); +}}; // ─── the sweep ────────────────────────────────────────────────────────────── @@ -257,73 +258,73 @@ struct EditedProvider { // The defect of #756: the provider declares `.ixx` and the consumer does not. // The root is swept with its own package's table, so the edit is seen. -TEST(DepSourcesNewerThan, AProvidersOwnExtensionMakesItsEditCount) { +UTest a_providers_own_extension_makes_its_edit_count{"DepSourcesNewerThan/AProvidersOwnExtensionMakesItsEditCount", [] { EditedProvider p; - EXPECT_TRUE(dep_sources_newer_than({{p.tmp.path, {".ixx"}, {}}}, p.ninjaTime)); -} + expect(bool(dep_sources_newer_than({{p.tmp.path, {".ixx"}, {}}}, p.ninjaTime))); +}}; // A root whose package declares nothing about `.ixx` does not have an `.ixx` of // interest, whatever any other package declares: the table is the root's own. -TEST(DepSourcesNewerThan, APackageThatDeclaresNothingHasNoInterestInTheFile) { +UTest a_package_that_declares_nothing_has_no_interest_in_the_file{"DepSourcesNewerThan/APackageThatDeclaresNothingHasNoInterestInTheFile", [] { EditedProvider p; - EXPECT_FALSE(dep_sources_newer_than({{p.tmp.path, {}, {}}}, p.ninjaTime)); -} + expect(!dep_sources_newer_than({{p.tmp.path, {}, {}}}, p.ninjaTime)); +}}; // One root's table does not leak into the next: of two roots holding the same // file, the one whose package declares the extension is the one that answers. -TEST(DepSourcesNewerThan, EachRootIsClassifiedByItsOwnTable) { +UTest each_root_is_classified_by_its_own_table{"DepSourcesNewerThan/EachRootIsClassifiedByItsOwnTable", [] { EditedProvider declares; EditedProvider silent; const auto ninjaTime = declares.ninjaTime; - EXPECT_FALSE(dep_sources_newer_than({{silent.tmp.path, {}, {}}}, ninjaTime)); - EXPECT_TRUE(dep_sources_newer_than( - {{silent.tmp.path, {}, {}}, {declares.tmp.path, {".ixx"}, {}}}, ninjaTime)); -} + expect(!dep_sources_newer_than({{silent.tmp.path, {}, {}}}, ninjaTime)); + expect(bool(dep_sources_newer_than( + {{silent.tmp.path, {}, {}}, {declares.tmp.path, {".ixx"}, {}}}, ninjaTime))); +}}; // What was always swept still is, with any table. -TEST(DepSourcesNewerThan, TheBuiltInInterfaceExtensionIsSweptWithoutADeclaration) { +UTest the_built_in_interface_extension_is_swept_without_a_declaration{"DepSourcesNewerThan/TheBuiltInInterfaceExtensionIsSweptWithoutADeclaration", [] { EditedProvider p; write_file(p.tmp.path / "m.cppm", "export module m;\n"); std::filesystem::last_write_time(p.tmp.path / "m.cppm", p.ninjaTime + std::chrono::seconds(20)); - EXPECT_TRUE(dep_sources_newer_than({{p.tmp.path, {}, {}}}, p.ninjaTime)); -} + expect(bool(dep_sources_newer_than({{p.tmp.path, {}, {}}}, p.ninjaTime))); +}}; -TEST(DepSourcesNewerThan, ANewerManifestCounts) { +UTest a_newer_manifest_counts{"DepSourcesNewerThan/ANewerManifestCounts", [] { EditedProvider p; std::filesystem::last_write_time(p.tmp.path / "mcpp.toml", p.ninjaTime + std::chrono::seconds(20)); - EXPECT_TRUE(dep_sources_newer_than({{p.tmp.path, {}, {}}}, p.ninjaTime)); -} + expect(bool(dep_sources_newer_than({{p.tmp.path, {}, {}}}, p.ninjaTime))); +}}; // The graph names the engine it runs as `$mcpp`, escaped for ninja; the reader // returns the path the emitter escaped, so that it compares with // `mcpp_exe_path()` in generic form. -TEST(ReadEngineBinding, TheEscapedPathIsReadBack) { +UTest the_escaped_path_is_read_back{"ReadEngineBinding/TheEscapedPathIsReadBack", [] { Tmp tmp; write_file(tmp.path / "build.ninja", "# mcpp:graph=normal\ncxx = /opt/llvm/bin/clang++\n" "mcpp = C$:/Program$ Files/mcpp$$1/mcpp.exe\n\nrule cxx\n"); - EXPECT_EQ(read_engine_binding(tmp.path / "build.ninja"), "C:/Program Files/mcpp$1/mcpp.exe"); -} + expect(eq(read_engine_binding(tmp.path / "build.ninja"), "C:/Program Files/mcpp$1/mcpp.exe")); +}}; // The binding is a command word, quoted for the host (#832): a POSIX graph // writes single quotes, a Windows graph double quotes. -TEST(ReadEngineBinding, TheQuotedWordIsReadBack) { +UTest the_quoted_word_is_read_back{"ReadEngineBinding/TheQuotedWordIsReadBack", [] { Tmp tmp; write_file(tmp.path / "build.ninja", "mcpp = '/tmp/mc probe/it'\\''s/$$1/mcpp'\n"); - EXPECT_EQ(read_engine_binding(tmp.path / "build.ninja"), "/tmp/mc probe/it's/$1/mcpp"); + expect(eq(read_engine_binding(tmp.path / "build.ninja"), "/tmp/mc probe/it's/$1/mcpp")); write_file(tmp.path / "build.ninja", "mcpp = \"C:/Program Files/mcpp/mcpp.exe\"\n"); - EXPECT_EQ(read_engine_binding(tmp.path / "build.ninja"), "C:/Program Files/mcpp/mcpp.exe"); -} + expect(eq(read_engine_binding(tmp.path / "build.ninja"), "C:/Program Files/mcpp/mcpp.exe")); +}}; -TEST(ReadEngineBinding, AGraphWithoutTheBindingSaysNothing) { +UTest a_graph_without_the_binding_says_nothing{"ReadEngineBinding/AGraphWithoutTheBindingSaysNothing", [] { Tmp tmp; write_file(tmp.path / "build.ninja", "cxx = clang++\nmcpp_extra = x\nrule cxx\n"); - EXPECT_EQ(read_engine_binding(tmp.path / "build.ninja"), ""); -} + expect(eq(read_engine_binding(tmp.path / "build.ninja"), "")); +}}; -TEST(ReadEngineBinding, AMissingFileSaysNothing) { +UTest a_missing_file_says_nothing{"ReadEngineBinding/AMissingFileSaysNothing", [] { Tmp tmp; - EXPECT_EQ(read_engine_binding(tmp.path / "absent.ninja"), ""); -} + expect(eq(read_engine_binding(tmp.path / "absent.ninja"), "")); +}}; diff --git a/tests/unit/test_build_database.cpp b/tests/unit/test_build_database.cpp index 66a7a7306..826164b14 100644 --- a/tests/unit/test_build_database.cpp +++ b/tests/unit/test_build_database.cpp @@ -5,9 +5,9 @@ // command builders, so a builder that changes shape fails here rather than in // a document an editor reads. -#include import std; +import mcpplibs.mtest; import mcpp.build.build_database; import mcpp.modgraph.graph; import mcpplibs.os.platform; @@ -16,6 +16,8 @@ import mcpp.toolchain.gcc; import mcpp.toolchain.model; import mcpp.toolchain.msvc; +using namespace mcpplibs::mtest; + namespace db = mcpp::build::database; using mcpp::modgraph::ModuleDeclaration; using namespace mcpp::toolchain; @@ -34,55 +36,52 @@ bool contains(const std::vector& words, const std::string& w) { } // namespace -TEST(BuildDatabase, RoleOfEachDeclarationForm) { - EXPECT_EQ(db::role_name(ModuleDeclaration::None), "non-module"); - EXPECT_EQ(db::role_name(ModuleDeclaration::Interface), "module-interface"); - EXPECT_EQ(db::role_name(ModuleDeclaration::InterfacePartition), - "module-partition-interface"); - EXPECT_EQ(db::role_name(ModuleDeclaration::ImplementationPartition), - "module-partition-implementation"); - EXPECT_EQ(db::role_name(ModuleDeclaration::Implementation), "module-implementation"); - EXPECT_EQ(db::role_name(ModuleDeclaration::Unknown), "unknown"); -} - -TEST(BuildDatabase, StandardLibraryNames) { - EXPECT_EQ(db::stdlib_name("libstdc++"), "libstdc++"); - EXPECT_EQ(db::stdlib_name("libc++"), "libc++"); - EXPECT_EQ(db::stdlib_name("msvc-stl"), "msvc-stl"); - EXPECT_EQ(db::stdlib_name("newlib"), "other"); -} - -TEST(BuildDatabase, ToolchainIdNamesFamilyVersionAndTheCompilersTriple) { +UTest role_of_each_declaration_form{"BuildDatabase/RoleOfEachDeclarationForm", [] { + expect(eq(db::role_name(ModuleDeclaration::None), "non-module")); + expect(eq(db::role_name(ModuleDeclaration::Interface), "module-interface")); + expect(eq(db::role_name(ModuleDeclaration::InterfacePartition), "module-partition-interface")); + expect(eq(db::role_name(ModuleDeclaration::ImplementationPartition), "module-partition-implementation")); + expect(eq(db::role_name(ModuleDeclaration::Implementation), "module-implementation")); + expect(eq(db::role_name(ModuleDeclaration::Unknown), "unknown")); +}}; + +UTest standard_library_names{"BuildDatabase/StandardLibraryNames", [] { + expect(eq(db::stdlib_name("libstdc++"), "libstdc++")); + expect(eq(db::stdlib_name("libc++"), "libc++")); + expect(eq(db::stdlib_name("msvc-stl"), "msvc-stl")); + expect(eq(db::stdlib_name("newlib"), "other")); +}}; + +UTest toolchain_id_names_family_version_and_the_compilers_triple{"BuildDatabase/ToolchainIdNamesFamilyVersionAndTheCompilersTriple", [] { Toolchain tc; tc.compiler = CompilerId::Clang; tc.version = "22.1.8"; tc.targetTriple = "x86_64-windows-msvc"; // mcpp's family name in the opaque id (`llvm`, as in `llvm@22.1.8`), and the // triple as the compiler spells it; the S1 `family` field says `clang`. - EXPECT_EQ(db::toolchain_id(tc, "x86_64-pc-windows-msvc"), - "llvm-22.1.8-x86_64-pc-windows-msvc"); - EXPECT_EQ(tc.compiler_name(), "clang"); -} + expect(eq(db::toolchain_id(tc, "x86_64-pc-windows-msvc"), "llvm-22.1.8-x86_64-pc-windows-msvc")); + expect(eq(tc.compiler_name(), "clang")); +}}; -TEST(BuildDatabase, PosixWordsUndoShellQuoting) { +UTest posix_words_undo_shell_quoting{"BuildDatabase/PosixWordsUndoShellQuoting", [] { auto w = db::split_command_words( "cd '/c d' && env LD_LIBRARY_PATH='/r t' 'it'\\''s' \"a\\\"b\" x\\ y 2>&1", /*windows=*/false); std::vector want{"cd", "/c d", "&&", "env", "LD_LIBRARY_PATH=/r t", "it's", "a\"b", "x y", "2>&1"}; - EXPECT_EQ(w, want); -} + expect(eq(w, want)); +}}; -TEST(BuildDatabase, WindowsWordsFollowTheRuntimeRules) { +UTest windows_words_follow_the_runtime_rules{"BuildDatabase/WindowsWordsFollowTheRuntimeRules", [] { auto w = db::split_command_words( "cd /d \"C:\\a b\" && \"C:\\t\\clang++.exe\" \"x\\\\\\\"y\" C:\\s\\std.ixx 2>&1", /*windows=*/true); std::vector want{"cd", "/d", "C:\\a b", "&&", "C:\\t\\clang++.exe", "x\\\"y", "C:\\s\\std.ixx", "2>&1"}; - EXPECT_EQ(w, want); -} + expect(eq(w, want)); +}}; -TEST(BuildDatabase, RecoversTheGccStdUnitFromItsBuilder) { +UTest recovers_the_gcc_std_unit_from_its_builder{"BuildDatabase/RecoversTheGccStdUnitFromItsBuilder", [] { Toolchain tc; tc.compiler = CompilerId::GCC; tc.version = "16.1.0"; @@ -94,22 +93,22 @@ TEST(BuildDatabase, RecoversTheGccStdUnitFromItsBuilder) { const auto cache = host_path("/home/u/.mcpp/cache/std/k1", "C:\\u\\.mcpp\\std\\k1"); auto commands = gcc::std_module_build_commands(tc, cache, "", "-std=c++23"); - ASSERT_FALSE(commands.empty()); + expect(fatal(!commands.empty())); auto inv = db::recover_invocation(commands, tc.stdModuleSource, tc.binaryPath, "/unused", kWindows); - ASSERT_TRUE(inv.has_value()) << commands.front(); - EXPECT_EQ(inv->workDirectory, cache) << commands.front(); - ASSERT_FALSE(inv->arguments.empty()); - EXPECT_EQ(inv->arguments.front(), tc.binaryPath.string()) << commands.front(); - EXPECT_TRUE(contains(inv->arguments, tc.stdModuleSource.string())) << commands.front(); - EXPECT_TRUE(contains(inv->arguments, "-std=c++23")) << commands.front(); - EXPECT_FALSE(contains(inv->arguments, "2>&1")) << commands.front(); - EXPECT_FALSE(contains(inv->arguments, "env")) << commands.front(); + expect(fatal(inv.has_value())) << commands.front(); + expect(eq(inv->workDirectory, cache)) << commands.front(); + expect(fatal(!(inv->arguments.empty()))); + expect(eq(inv->arguments.front(), tc.binaryPath.string())) << commands.front(); + expect(contains(inv->arguments, tc.stdModuleSource.string())) << commands.front(); + expect(contains(inv->arguments, "-std=c++23")) << commands.front(); + expect(!contains(inv->arguments, "2>&1")) << commands.front(); + expect(!contains(inv->arguments, "env")) << commands.front(); for (auto const& a : inv->arguments) - EXPECT_FALSE(a.starts_with("LD_LIBRARY_PATH=")) << a; -} + expect(!a.starts_with("LD_LIBRARY_PATH=")) << a; +}}; -TEST(BuildDatabase, RecoversTheClangStdUnitsFromTheirBuilders) { +UTest recovers_the_clang_std_units_from_their_builders{"BuildDatabase/RecoversTheClangStdUnitsFromTheirBuilders", [] { Toolchain tc; tc.compiler = CompilerId::Clang; tc.version = "22.1.8"; @@ -125,21 +124,21 @@ TEST(BuildDatabase, RecoversTheClangStdUnitsFromTheirBuilders) { auto stdCommands = clang::std_module_build_commands(tc, cache, bmi, "", "-std=c++23"); auto inv = db::recover_invocation(stdCommands, tc.stdModuleSource, tc.binaryPath, cache, kWindows); - ASSERT_TRUE(inv.has_value()) << stdCommands.front(); - EXPECT_EQ(inv->arguments.front(), tc.binaryPath.string()); - EXPECT_TRUE(contains(inv->arguments, "--precompile")) << stdCommands.front(); - EXPECT_TRUE(contains(inv->arguments, tc.stdModuleSource.string())); - EXPECT_EQ(inv->workDirectory, cache); + expect(fatal(inv.has_value())) << stdCommands.front(); + expect(eq(inv->arguments.front(), tc.binaryPath.string())); + expect(contains(inv->arguments, "--precompile")) << stdCommands.front(); + expect(contains(inv->arguments, tc.stdModuleSource.string())); + expect(eq(inv->workDirectory, cache)); auto compatCommands = clang::std_compat_build_commands( tc, cache, clang::std_compat_bmi_path(cache), bmi, "", "-std=c++23"); auto compat = db::recover_invocation(compatCommands, tc.stdCompatSource, tc.binaryPath, cache, kWindows); - ASSERT_TRUE(compat.has_value()) << compatCommands.front(); - EXPECT_TRUE(contains(compat->arguments, tc.stdCompatSource.string())); -} + expect(fatal(compat.has_value())) << compatCommands.front(); + expect(contains(compat->arguments, tc.stdCompatSource.string())); +}}; -TEST(BuildDatabase, RecoversTheMsvcStdUnitsFromTheirBuilders) { +UTest recovers_the_msvc_std_units_from_their_builders{"BuildDatabase/RecoversTheMsvcStdUnitsFromTheirBuilders", [] { Toolchain tc; tc.compiler = CompilerId::MSVC; tc.version = "19.44.35207"; @@ -156,33 +155,33 @@ TEST(BuildDatabase, RecoversTheMsvcStdUnitsFromTheirBuilders) { auto stdCommands = msvc::std_module_build_commands(tc, cache, "/std:c++latest", "/MD"); auto inv = db::recover_invocation(stdCommands, tc.stdModuleSource, tc.binaryPath, "/unused", kWindows); - ASSERT_TRUE(inv.has_value()) << stdCommands.front(); - EXPECT_EQ(inv->workDirectory, cache) << stdCommands.front(); - EXPECT_EQ(inv->arguments.front(), tc.binaryPath.string()) << stdCommands.front(); - EXPECT_TRUE(contains(inv->arguments, "/std:c++latest")) << stdCommands.front(); - EXPECT_TRUE(contains(inv->arguments, tc.stdModuleSource.string())) << stdCommands.front(); - EXPECT_FALSE(contains(inv->arguments, "2>&1")) << stdCommands.front(); + expect(fatal(inv.has_value())) << stdCommands.front(); + expect(eq(inv->workDirectory, cache)) << stdCommands.front(); + expect(eq(inv->arguments.front(), tc.binaryPath.string())) << stdCommands.front(); + expect(contains(inv->arguments, "/std:c++latest")) << stdCommands.front(); + expect(contains(inv->arguments, tc.stdModuleSource.string())) << stdCommands.front(); + expect(!contains(inv->arguments, "2>&1")) << stdCommands.front(); auto compatCommands = msvc::std_compat_build_commands(tc, cache, "/std:c++latest", "/MD"); auto compat = db::recover_invocation(compatCommands, tc.stdCompatSource, tc.binaryPath, "/unused", kWindows); - ASSERT_TRUE(compat.has_value()) << compatCommands.front(); - EXPECT_TRUE(contains(compat->arguments, "/reference")) << compatCommands.front(); -} + expect(fatal(compat.has_value())) << compatCommands.front(); + expect(contains(compat->arguments, "/reference")) << compatCommands.front(); +}}; -TEST(BuildDatabase, NoCommandNamingTheSourceRecoversNothing) { +UTest no_command_naming_the_source_recovers_nothing{"BuildDatabase/NoCommandNamingTheSourceRecoversNothing", [] { auto inv = db::recover_invocation({"cd /c && /bin/g++ -c other.cc -o o.o"}, "/src/std.cc", "/bin/g++", "/c", false); - EXPECT_FALSE(inv.has_value()); -} + expect(!inv.has_value()); +}}; -TEST(BuildDatabase, AnUnquotedDriverWithASpaceIsRejoined) { +UTest an_unquoted_driver_with_a_space_is_rejoined{"BuildDatabase/AnUnquotedDriverWithASpaceIsRejoined", [] { auto inv = db::recover_invocation( {"C:\\Program Files\\LLVM\\bin\\clang++.exe -std=c++23 --precompile " "\"C:\\s\\std.cppm\" -o \"C:\\c\\std.pcm\""}, "C:\\s\\std.cppm", "C:\\Program Files\\LLVM\\bin\\clang++.exe", "C:\\c", /*windows=*/true); - ASSERT_TRUE(inv.has_value()); - EXPECT_EQ(inv->arguments.front(), "C:\\Program Files\\LLVM\\bin\\clang++.exe"); - EXPECT_EQ(inv->arguments[1], "-std=c++23"); -} + expect(fatal(inv.has_value())); + expect(eq(inv->arguments.front(), "C:\\Program Files\\LLVM\\bin\\clang++.exe")); + expect(eq(inv->arguments[1], "-std=c++23")); +}}; diff --git a/tests/unit/test_build_directives.cpp b/tests/unit/test_build_directives.cpp index 2d3d72578..468e8d64c 100644 --- a/tests/unit/test_build_directives.cpp +++ b/tests/unit/test_build_directives.cpp @@ -1,10 +1,12 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.directives; import mcpp.manifest; import mcpp.toolchain.dialect; +using namespace mcpplibs::mtest; + // The directive table is the single definition of what a `build.mcpp` // directive IS. Before it existed, a directive was defined in nine places and // adding one meant editing all of them — the failure mode being that you edit @@ -48,34 +50,34 @@ dirs::Directives parse(std::string_view text) { // ── Table integrity ──────────────────────────────────────────────────────── -TEST(BuildDirectives, EveryRowIsInternallyConsistent) { +UTest every_row_is_internally_consistent{"BuildDirectives/EveryRowIsInternallyConsistent", [] { for (auto const& def : dirs::kTable) { - EXPECT_FALSE(def.wire.empty()); - EXPECT_GT(def.sinceProtocol, 0) << def.wire; - EXPECT_LE(def.sinceProtocol, dirs::kProtocolVersion) << def.wire; + expect(!def.wire.empty()); + expect(gt(def.sinceProtocol, 0)) << def.wire; + expect(le(def.sinceProtocol, dirs::kProtocolVersion)) << def.wire; // A RerunKey directive feeds only the re-run key, so it must NOT be // persisted as a `d` record; everything else must be, or a cache hit // would silently apply less than the program asked for. if (def.scope == dirs::Scope::RerunKey) - EXPECT_TRUE(def.tag.empty()) << def.wire; + expect(def.tag.empty()) << def.wire; else - EXPECT_FALSE(def.tag.empty()) << def.wire; + expect(!def.tag.empty()) << def.wire; // The declared-output contract needs a diagnostic that says WHICH // contract was broken. if (def.mustExistAfterRun) { - EXPECT_FALSE(def.missingPrefix.empty()) << def.wire; - EXPECT_FALSE(def.missingSuffix.empty()) << def.wire; + expect(!def.missingPrefix.empty()) << def.wire; + expect(!def.missingSuffix.empty()) << def.wire; } } -} +}}; -TEST(BuildDirectives, WireNamesAreUnique) { +UTest wire_names_are_unique{"BuildDirectives/WireNamesAreUnique", [] { std::set seen; for (auto const& def : dirs::kTable) - EXPECT_TRUE(seen.insert(def.wire).second) << "duplicate wire " << def.wire; -} + expect(bool(seen.insert(def.wire).second)) << "duplicate wire " << def.wire; +}}; -TEST(BuildDirectives, RowsSharingATagShareASlot) { +UTest rows_sharing_a_tag_share_a_slot{"BuildDirectives/RowsSharingATagShareASlot", [] { // Cache deserialization resolves a tag to exactly one slot, so two rows // with the same tag but different slots would round-trip to the wrong // channel. link-lib and link-search legitimately share `ldflag`. @@ -83,36 +85,36 @@ TEST(BuildDirectives, RowsSharingATagShareASlot) { for (auto const& def : dirs::kTable) { if (def.tag.empty()) continue; auto [it, fresh] = slotOfTag.try_emplace(def.tag, def.slot); - if (!fresh) EXPECT_EQ(it->second, def.slot) << def.tag; + if (!fresh) expect(eq(it->second, def.slot)) << def.tag; } -} +}}; -TEST(BuildDirectives, LookupsAgree) { +UTest lookups_agree{"BuildDirectives/LookupsAgree", [] { for (auto const& def : dirs::kTable) { - ASSERT_NE(dirs::find_by_wire(def.wire), nullptr) << def.wire; - EXPECT_EQ(dirs::find_by_wire(def.wire)->slot, def.slot) << def.wire; + expect(fatal(ne(dirs::find_by_wire(def.wire), nullptr))) << def.wire; + expect(eq(dirs::find_by_wire(def.wire)->slot, def.slot)) << def.wire; if (!def.tag.empty()) { - ASSERT_NE(dirs::find_by_tag(def.tag), nullptr) << def.tag; - EXPECT_EQ(dirs::find_by_tag(def.tag)->slot, def.slot) << def.tag; + expect(fatal(ne(dirs::find_by_tag(def.tag), nullptr))) << def.tag; + expect(eq(dirs::find_by_tag(def.tag)->slot, def.slot)) << def.tag; } } - EXPECT_EQ(dirs::find_by_wire("no-such-directive"), nullptr); + expect(eq(dirs::find_by_wire("no-such-directive"), nullptr)); // The empty tag belongs to the rerun rows and must never resolve, or a // malformed cache line would be silently accepted into a slot. - EXPECT_EQ(dirs::find_by_tag(""), nullptr); -} + expect(eq(dirs::find_by_tag(""), nullptr)); +}}; // ── Parsing ──────────────────────────────────────────────────────────────── -TEST(BuildDirectives, NonDirectiveLinesAreIgnored) { +UTest non_directive_lines_are_ignored{"BuildDirectives/NonDirectiveLinesAreIgnored", [] { auto d = parse("hello\nmcpp is not a directive\n \n"); for (std::size_t i = 0; i < dirs::kSlotCount; ++i) - EXPECT_TRUE(d.slots[i].empty()); - EXPECT_TRUE(d.unknownKeys.empty()); - EXPECT_EQ(d.protocol, 0); -} + expect(d.slots[i].empty()); + expect(d.unknownKeys.empty()); + expect(eq(d.protocol, 0)); +}}; -TEST(BuildDirectives, TransformsAreAppliedOnceAtParseTime) { +UTest transforms_are_applied_once_at_parse_time{"BuildDirectives/TransformsAreAppliedOnceAtParseTime", [] { // An input that is already absolute ON THIS PLATFORM, so the "taken as-is" // half of the assertion tests what it claims to. const auto alreadyAbs = @@ -125,104 +127,100 @@ TEST(BuildDirectives, TransformsAreAppliedOnceAtParseTime) { "mcpp:include-dir=inc\n" "mcpp:include-dir-after={}\n", alreadyAbs.string())); - EXPECT_EQ(d.at(dirs::Slot::CxxFlags), (std::vector{"-Wall"})); + expect(eq(d.at(dirs::Slot::CxxFlags), (std::vector{"-Wall"}))); // link-lib and link-search share the ldflags slot, in emission order. - EXPECT_EQ(d.at(dirs::Slot::LdFlags), - (std::vector{"-lz", "-L" + under_root("vendor/lib")})); - EXPECT_EQ(d.at(dirs::Slot::Defines), (std::vector{"-DHAVE_X"})); + expect(eq(d.at(dirs::Slot::LdFlags), (std::vector{"-lz", "-L" + under_root("vendor/lib")}))); + expect(eq(d.at(dirs::Slot::Defines), (std::vector{"-DHAVE_X"}))); // Relative resolves against the package root; absolute is taken as-is. - EXPECT_EQ(d.at(dirs::Slot::IncludeDirs), - (std::vector{under_root("inc")})); - EXPECT_EQ(d.at(dirs::Slot::IncludeDirsAfter), - (std::vector{alreadyAbs.string()})); -} + expect(eq(d.at(dirs::Slot::IncludeDirs), (std::vector{under_root("inc")}))); + expect(eq(d.at(dirs::Slot::IncludeDirsAfter), (std::vector{alreadyAbs.string()}))); +}}; -TEST(BuildDirectives, DialectDecidesTheSpelling) { +UTest dialect_decides_the_spelling{"BuildDirectives/DialectDecidesTheSpelling", [] { dirs::Directives d; dirs::accept_output(d, mcpp::toolchain::msvc_dialect(), test_root(), "mcpp:link-lib=z\nmcpp:cfg=HAVE_X\n"); - EXPECT_EQ(d.at(dirs::Slot::LdFlags), (std::vector{"z.lib"})); - EXPECT_EQ(d.at(dirs::Slot::Defines), (std::vector{"/DHAVE_X"})); -} + expect(eq(d.at(dirs::Slot::LdFlags), (std::vector{"z.lib"}))); + expect(eq(d.at(dirs::Slot::Defines), (std::vector{"/DHAVE_X"}))); +}}; -TEST(BuildDirectives, ValuesMayContainSpacesAndEqualsSigns) { +UTest values_may_contain_spaces_and_equals_signs{"BuildDirectives/ValuesMayContainSpacesAndEqualsSigns", [] { // Everything after the FIRST '=' is the value — a path with a space or a // define with an '=' must survive intact. auto d = parse("mcpp:cxxflag=-DMSG=\"a b\"\n"); - EXPECT_EQ(d.at(dirs::Slot::CxxFlags), - (std::vector{"-DMSG=\"a b\""})); -} + expect(eq(d.at(dirs::Slot::CxxFlags), (std::vector{"-DMSG=\"a b\""}))); +}}; // ── Protocol ─────────────────────────────────────────────────────────────── -TEST(BuildDirectives, UnknownKeyWithoutAnAnnouncementIsTolerated) { +UTest unknown_key_without_an_announcement_is_tolerated{"BuildDirectives/UnknownKeyWithoutAnAnnouncementIsTolerated", [] { // A hand-written printf program announces nothing; its surface is frozen, // so an unknown key is a typo the engine warns about rather than a // forward-compat situation it must refuse. auto d = parse("mcpp:no-such-thing=1\n"); - EXPECT_EQ(d.protocol, 0); - EXPECT_EQ(d.unknownKeys, (std::vector{"no-such-thing"})); - EXPECT_FALSE(dirs::protocol_error(d).has_value()); -} + expect(eq(d.protocol, 0)); + expect(eq(d.unknownKeys, (std::vector{"no-such-thing"}))); + expect(!(dirs::protocol_error(d).has_value())); +}}; -TEST(BuildDirectives, UnknownKeyWithAnAnnouncementIsFatal) { +UTest unknown_key_with_an_announcement_is_fatal{"BuildDirectives/UnknownKeyWithAnAnnouncementIsFatal", [] { auto d = parse("mcpp:protocol=1\nmcpp:no-such-thing=1\n"); - EXPECT_EQ(d.protocol, 1); + expect(eq(d.protocol, 1)); auto err = dirs::protocol_error(d); - ASSERT_TRUE(err.has_value()); - EXPECT_NE(err->find("no-such-thing"), std::string::npos); -} + expect(fatal(err.has_value())); + expect(ne(err->find("no-such-thing"), std::string::npos)); +}}; -TEST(BuildDirectives, NewerProtocolIsFatalAndSaysWhatToDo) { +UTest newer_protocol_is_fatal_and_says_what_to_do{"BuildDirectives/NewerProtocolIsFatalAndSaysWhatToDo", [] { auto d = parse("mcpp:protocol=999\n"); auto err = dirs::protocol_error(d); - ASSERT_TRUE(err.has_value()); - EXPECT_NE(err->find("999"), std::string::npos); + expect(fatal(err.has_value())); + expect(ne(err->find("999"), std::string::npos)); // The message has to tell the user the actionable thing, not just that // something is wrong. - EXPECT_NE(err->find("upgrade"), std::string::npos); -} + expect(ne(err->find("upgrade"), std::string::npos)); +}}; -TEST(BuildDirectives, CurrentProtocolIsAccepted) { +UTest current_protocol_is_accepted{"BuildDirectives/CurrentProtocolIsAccepted", [] { auto d = parse(std::format("mcpp:protocol={}\nmcpp:cxxflag=-Wall\n", dirs::kProtocolVersion)); - EXPECT_FALSE(dirs::protocol_error(d).has_value()); - EXPECT_EQ(d.at(dirs::Slot::CxxFlags), (std::vector{"-Wall"})); -} + expect(!(dirs::protocol_error(d).has_value())); + expect(eq(d.at(dirs::Slot::CxxFlags), (std::vector{"-Wall"}))); +}}; -TEST(BuildDirectives, UnknownKeysAreDeduplicated) { +UTest unknown_keys_are_deduplicated{"BuildDirectives/UnknownKeysAreDeduplicated", [] { auto d = parse("mcpp:zzz=1\nmcpp:zzz=2\nmcpp:yyy=3\n"); - EXPECT_EQ(d.unknownKeys, (std::vector{"zzz", "yyy"})); -} + expect(eq(d.unknownKeys, (std::vector{"zzz", "yyy"}))); +}}; // #693: a directive whose text is not UTF-8 names a different file once it is // in build.ninja, so it is refused by key, whatever protocol the program // speaks, and nothing of it is applied. -TEST(BuildDirectives, ADirectiveThatIsNotUtf8IsRefusedByKey) { +UTest a_directive_that_is_not_utf8_is_refused_by_key{"BuildDirectives/ADirectiveThatIsNotUtf8IsRefusedByKey", [] { // "caf" + 0xE9: Latin-1, the shape a narrow program in a code page 1252 // process prints. auto d = parse("mcpp:include-dir=inc/caf\xE9\nmcpp:cxxflag=-Wall\n"); - EXPECT_TRUE(d.at(dirs::Slot::IncludeDirs).empty()); - EXPECT_EQ(d.at(dirs::Slot::CxxFlags), (std::vector{"-Wall"})); - EXPECT_EQ(d.nonUtf8Keys, (std::vector{"mcpp:include-dir"})); + expect(d.at(dirs::Slot::IncludeDirs).empty()); + expect(eq(d.at(dirs::Slot::CxxFlags), (std::vector{"-Wall"}))); + expect(eq(d.nonUtf8Keys, (std::vector{"mcpp:include-dir"}))); auto err = dirs::encoding_error(d); - ASSERT_TRUE(err.has_value()); - EXPECT_NE(err->find("mcpp:include-dir"), std::string::npos) << *err; - EXPECT_NE(err->find("UTF-8"), std::string::npos) << *err; -} + expect(fatal(err.has_value())); + if (auto&& failed = expect(ne(err->find("mcpp:include-dir"), std::string::npos)); !failed) failed << *err; + if (auto&& failed = expect(ne(err->find("UTF-8"), std::string::npos)); !failed) failed << *err; +}}; -TEST(BuildDirectives, UtfEightDirectivesAndOtherChatterPass) { +UTest utf_eight_directives_and_other_chatter_pass{"BuildDirectives/UtfEightDirectivesAndOtherChatterPass", [] { // U+00E9 in UTF-8 is accepted; a line that is not a directive is not // examined at all. auto d = parse("mcpp:include-dir=inc/caf\xC3\xA9\nnoise \xE9\n"); - EXPECT_EQ(d.at(dirs::Slot::IncludeDirs).size(), 1u); - EXPECT_TRUE(d.nonUtf8Keys.empty()); - EXPECT_FALSE(dirs::encoding_error(d).has_value()); -} + expect(eq(d.at(dirs::Slot::IncludeDirs).size(), 1u)); + expect(d.nonUtf8Keys.empty()); + expect(!(dirs::encoding_error(d).has_value())); +}}; // ── Cache round-trip ─────────────────────────────────────────────────────── -TEST(BuildDirectives, SerializeDeserializeRoundTrip) { +UTest serialize_deserialize_round_trip{"BuildDirectives/SerializeDeserializeRoundTrip", [] { auto d = parse("mcpp:cxxflag=-Wall\n" "mcpp:cflag=-std=c11\n" "mcpp:link-lib=z\n" @@ -245,21 +243,21 @@ TEST(BuildDirectives, SerializeDeserializeRoundTrip) { std::istringstream is(os.str()); std::string line; while (std::getline(is, line)) { - ASSERT_TRUE(line.starts_with("d ")); + expect(fatal(line.starts_with("d "))); auto rest = line.substr(2); auto sp = rest.find(' '); - ASSERT_NE(sp, std::string::npos); - EXPECT_TRUE(dirs::accept_cache_record(back, rest.substr(0, sp), - rest.substr(sp + 1))); + expect(fatal(ne(sp, std::string::npos))); + expect(bool(dirs::accept_cache_record(back, rest.substr(0, sp), + rest.substr(sp + 1)))); } // Every persisted slot survives verbatim; the rerun slots deliberately do // not (they are re-derived from their own cache records). for (auto const& def : dirs::kTable) { if (def.tag.empty()) continue; - EXPECT_EQ(back.at(def.slot), d.at(def.slot)) << def.wire; + expect(eq(back.at(def.slot), d.at(def.slot))) << def.wire; } -} +}}; // ── The probe channel ────────────────────────────────────────────────────── // @@ -268,45 +266,43 @@ TEST(BuildDirectives, SerializeDeserializeRoundTrip) { // so the version-floor check in prepare reads one list and never learns // which spelling a claim came in. -TEST(BuildDirectives, FactsAndFloorsAreClaimsThatFoldIntoRuntimeDeclarations) { +UTest facts_and_floors_are_claims_that_fold_into_runtime_declarations{"BuildDirectives/FactsAndFloorsAreClaimsThatFoldIntoRuntimeDeclarations", [] { auto d = parse("mcpp:fact=widget.driver=1.2\n" "mcpp:floor=widget.driver >= 2.0\n"); - EXPECT_EQ(d.at(dirs::Slot::Facts), (std::vector{"widget.driver=1.2"})); - EXPECT_EQ(d.at(dirs::Slot::Floors), (std::vector{"widget.driver >= 2.0"})); + expect(eq(d.at(dirs::Slot::Facts), (std::vector{"widget.driver=1.2"}))); + expect(eq(d.at(dirs::Slot::Floors), (std::vector{"widget.driver >= 2.0"}))); mcpp::manifest::Manifest m; dirs::apply(m, d); - ASSERT_EQ(m.runtimeConfig.provides.size(), 1u); - EXPECT_EQ(m.runtimeConfig.provides[0], "widget.driver=1.2"); - ASSERT_EQ(m.runtimeConfig.requirements.size(), 1u); - EXPECT_EQ(m.runtimeConfig.requirements[0].kind, "version-floor"); - EXPECT_EQ(m.runtimeConfig.requirements[0].value, "widget.driver >= 2.0"); + expect(fatal(eq(m.runtimeConfig.provides.size(), 1u))); + expect(eq(m.runtimeConfig.provides[0], "widget.driver=1.2")); + expect(fatal(eq(m.runtimeConfig.requirements.size(), 1u))); + expect(eq(m.runtimeConfig.requirements[0].kind, "version-floor")); + expect(eq(m.runtimeConfig.requirements[0].value, "widget.driver >= 2.0")); // A claim about the build, not about the link or the run: the phase says // when it is decided. - EXPECT_EQ(m.runtimeConfig.requirements[0].phase, "build"); -} + expect(eq(m.runtimeConfig.requirements[0].phase, "build")); +}}; -TEST(BuildDirectives, APackFormatIsANameCarriedAndNotInterpreted) { +UTest a_pack_format_is_a_name_carried_and_not_interpreted{"BuildDirectives/APackFormatIsANameCarriedAndNotInterpreted", [] { auto d = parse("mcpp:pack-format=appimage\n" "mcpp:pack-format=msi\n"); - EXPECT_EQ(d.at(dirs::Slot::PackFormats), - (std::vector{"appimage", "msi"})); + expect(eq(d.at(dirs::Slot::PackFormats), (std::vector{"appimage", "msi"}))); mcpp::manifest::Manifest m; dirs::apply(m, d); - EXPECT_EQ(m.buildConfig.packFormats, - (std::vector{"appimage", "msi"})); + expect(eq(m.buildConfig.packFormats, (std::vector{"appimage", "msi"}))); // The engine holds the DISPATCH and no format, so a name reaches neither // the compile line nor the link line. A row that leaked into either would // put dpkg's or WiX's vocabulary on a command line. - EXPECT_TRUE(m.buildConfig.cflags.empty()); - EXPECT_TRUE(m.buildConfig.cxxflags.empty()); - EXPECT_TRUE(m.buildConfig.ldflags.empty()); - EXPECT_TRUE(m.buildConfig.sources.empty()); - EXPECT_TRUE(m.buildConfig.actions.empty()); -} - -TEST(BuildDirectives, APackFormatDeclarationIsPersisted) { + expect(m.buildConfig.cflags.empty()); + expect(m.buildConfig.cxxflags.empty()); + expect(m.buildConfig.ldflags.empty()); + expect(m.buildConfig.sources.empty()); + expect(m.buildConfig.actions.empty()); +}}; + +UTest a_pack_format_declaration_is_persisted{"BuildDirectives/APackFormatDeclarationIsPersisted", [] { // THE HALF THAT WOULD OTHERWISE BE MISSED. A build program's result is // cached and a hit does not re-run it, and the pass that READS this set is // `mcpp pack`, which is never a project's first build. A declaration that @@ -318,48 +314,48 @@ TEST(BuildDirectives, APackFormatDeclarationIsPersisted) { // tagged row, so this only has to hold the field that puts the row in that // set -- which is the field a new row is most likely to be added without. const auto* def = dirs::find_by_wire("pack-format"); - ASSERT_NE(def, nullptr); - EXPECT_FALSE(def->tag.empty()) << "a tag-less row is not replayed on a cache hit"; - EXPECT_EQ(def->slot, dirs::Slot::PackFormats); - EXPECT_EQ(def->scope, dirs::Scope::Claim); -} + expect(fatal(ne(def, nullptr))); + expect(!(def->tag.empty())) << "a tag-less row is not replayed on a cache hit"; + expect(eq(def->slot, dirs::Slot::PackFormats)); + expect(eq(def->scope, dirs::Scope::Claim)); +}}; -TEST(BuildDirectives, AClaimReachesNeitherCompileNorLink) { +UTest a_claim_reaches_neither_compile_nor_link{"BuildDirectives/AClaimReachesNeitherCompileNorLink", [] { auto d = parse("mcpp:fact=widget.driver=1.2\n" "mcpp:floor=widget.driver >= 2.0\n"); mcpp::manifest::Manifest m; dirs::apply(m, d); - EXPECT_TRUE(m.buildConfig.cflags.empty()); - EXPECT_TRUE(m.buildConfig.cxxflags.empty()); - EXPECT_TRUE(m.buildConfig.ldflags.empty()); - EXPECT_TRUE(m.buildConfig.sources.empty()); + expect(m.buildConfig.cflags.empty()); + expect(m.buildConfig.cxxflags.empty()); + expect(m.buildConfig.ldflags.empty()); + expect(m.buildConfig.sources.empty()); for (auto const& def : dirs::kTable) if (def.slot == dirs::Slot::Facts || def.slot == dirs::Slot::Floors) - EXPECT_EQ(def.scope, dirs::Scope::Claim) << def.wire; -} + expect(eq(def.scope, dirs::Scope::Claim)) << def.wire; +}}; -TEST(BuildDirectives, RerunSlotsAreNotPersistedAsDirectives) { +UTest rerun_slots_are_not_persisted_as_directives{"BuildDirectives/RerunSlotsAreNotPersistedAsDirectives", [] { auto d = parse("mcpp:rerun-if-changed=config.h\n" "mcpp:rerun-if-env-changed=USE_FAST\n"); - EXPECT_EQ(d.at(dirs::Slot::RerunFiles), (std::vector{"config.h"})); - EXPECT_EQ(d.at(dirs::Slot::RerunEnv), (std::vector{"USE_FAST"})); + expect(eq(d.at(dirs::Slot::RerunFiles), (std::vector{"config.h"}))); + expect(eq(d.at(dirs::Slot::RerunEnv), (std::vector{"USE_FAST"}))); std::ostringstream os; dirs::serialize(os, d); - EXPECT_TRUE(os.str().empty()); -} + expect(os.str().empty()); +}}; -TEST(BuildDirectives, UnknownCacheTagIsRejectedRatherThanDropped) { +UTest unknown_cache_tag_is_rejected_rather_than_dropped{"BuildDirectives/UnknownCacheTagIsRejectedRatherThanDropped", [] { // A cache written by a NEWER mcpp carries tags this one does not know. // Silently skipping them would apply a strict subset of what the program // asked for; the caller turns `false` into "entry is stale". dirs::Directives d; - EXPECT_FALSE(dirs::accept_cache_record(d, "some-future-tag", "value")); - EXPECT_TRUE(dirs::accept_cache_record(d, "cxxflag", "-Wall")); -} + expect(!dirs::accept_cache_record(d, "some-future-tag", "value")); + expect(bool(dirs::accept_cache_record(d, "cxxflag", "-Wall"))); +}}; // ── Apply ────────────────────────────────────────────────────────────────── -TEST(BuildDirectives, ApplyRoutesEachSlotToItsManifestChannel) { +UTest apply_routes_each_slot_to_its_manifest_channel{"BuildDirectives/ApplyRoutesEachSlotToItsManifestChannel", [] { auto d = parse("mcpp:cxxflag=-Wall\n" "mcpp:cflag=-std=c11\n" "mcpp:link-lib=z\n" @@ -373,41 +369,31 @@ TEST(BuildDirectives, ApplyRoutesEachSlotToItsManifestChannel) { dirs::apply(m, d); auto const& bc = m.buildConfig; - EXPECT_NE(std::find(bc.cxxflags.begin(), bc.cxxflags.end(), "-Wall"), - bc.cxxflags.end()); - EXPECT_NE(std::find(bc.cflags.begin(), bc.cflags.end(), "-std=c11"), - bc.cflags.end()); - EXPECT_NE(std::find(bc.ldflags.begin(), bc.ldflags.end(), "-lz"), - bc.ldflags.end()); + expect(ne(std::find(bc.cxxflags.begin(), bc.cxxflags.end(), "-Wall"), bc.cxxflags.end())); + expect(ne(std::find(bc.cflags.begin(), bc.cflags.end(), "-std=c11"), bc.cflags.end())); + expect(ne(std::find(bc.ldflags.begin(), bc.ldflags.end(), "-lz"), bc.ldflags.end())); // A cfg define colours BOTH language channels — the one slot that fans out. - EXPECT_NE(std::find(bc.cflags.begin(), bc.cflags.end(), "-DHAVE_X"), - bc.cflags.end()); - EXPECT_NE(std::find(bc.cxxflags.begin(), bc.cxxflags.end(), "-DHAVE_X"), - bc.cxxflags.end()); + expect(ne(std::find(bc.cflags.begin(), bc.cflags.end(), "-DHAVE_X"), bc.cflags.end())); + expect(ne(std::find(bc.cxxflags.begin(), bc.cxxflags.end(), "-DHAVE_X"), bc.cxxflags.end())); // generated= and source= must reach BOTH source lists: the scanner walks // the legacy modules.sources mirror, and a file missing from it is // invisible to the module scan. for (auto const& s : {"src/gen.cpp", "vendor/pick.cpp"}) { - EXPECT_NE(std::find(bc.sources.begin(), bc.sources.end(), s), - bc.sources.end()) << s; - EXPECT_NE(std::find(m.modules.sources.begin(), m.modules.sources.end(), s), - m.modules.sources.end()) << s; + expect(ne(std::find(bc.sources.begin(), bc.sources.end(), s), bc.sources.end())) << s; + expect(ne(std::find(m.modules.sources.begin(), m.modules.sources.end(), s), m.modules.sources.end())) << s; } - EXPECT_NE(std::find(bc.includeDirs.begin(), bc.includeDirs.end(), - std::filesystem::path(under_root("inc"))), - bc.includeDirs.end()); - EXPECT_NE(std::find(bc.includeDirsAfter.begin(), bc.includeDirsAfter.end(), - std::filesystem::path(under_root("after"))), - bc.includeDirsAfter.end()); -} + expect(ne(std::find(bc.includeDirs.begin(), bc.includeDirs.end(), + std::filesystem::path(under_root("inc"))), bc.includeDirs.end())); + expect(ne(std::find(bc.includeDirsAfter.begin(), bc.includeDirsAfter.end(), + std::filesystem::path(under_root("after"))), bc.includeDirsAfter.end())); +}}; -TEST(BuildDirectives, ApplyAppendsRatherThanReplaces) { +UTest apply_appends_rather_than_replaces{"BuildDirectives/ApplyAppendsRatherThanReplaces", [] { mcpp::manifest::Manifest m; m.buildConfig.cxxflags.push_back("-O2"); dirs::apply(m, parse("mcpp:cxxflag=-Wall\n")); - EXPECT_EQ(m.buildConfig.cxxflags, - (std::vector{"-O2", "-Wall"})); -} + expect(eq(m.buildConfig.cxxflags, (std::vector{"-O2", "-Wall"}))); +}}; // ── Private-scope fold ───────────────────────────────────────────────────── @@ -422,7 +408,7 @@ struct FakeUsage { }; } // namespace -TEST(BuildDirectives, FoldMovesOnlyTheTailAndOnlyPrivateChannels) { +UTest fold_moves_only_the_tail_and_only_private_channels{"BuildDirectives/FoldMovesOnlyTheTailAndOnlyPrivateChannels", [] { mcpp::manifest::Manifest m; m.buildConfig.cxxflags.push_back("-O2"); // pre-existing, not a directive m.buildConfig.includeDirs.emplace_back("/pre"); @@ -437,16 +423,15 @@ TEST(BuildDirectives, FoldMovesOnlyTheTailAndOnlyPrivateChannels) { dirs::fold_private_tail(priv, m, before); // Only what the program added, and only the private channels. - EXPECT_EQ(priv.cxxflags, (std::vector{"-Wall", "-DHAVE_X"})); - EXPECT_EQ(priv.cflags, (std::vector{"-DHAVE_X"})); - EXPECT_EQ(priv.includeDirs, - (std::vector{under_root("inc")})); + expect(eq(priv.cxxflags, (std::vector{"-Wall", "-DHAVE_X"}))); + expect(eq(priv.cflags, (std::vector{"-DHAVE_X"}))); + expect(eq(priv.includeDirs, (std::vector{under_root("inc")}))); // Link flags are NOT private — they reach the final link through their own // path, and folding them here would double-apply them. - EXPECT_TRUE(priv.includeDirsAfter.empty()); -} + expect(priv.includeDirsAfter.empty()); +}}; -TEST(BuildDirectives, FoldIsIdempotentOnIncludeDirs) { +UTest fold_is_idempotent_on_include_dirs{"BuildDirectives/FoldIsIdempotentOnIncludeDirs", [] { // Include dirs are unique-appended: the same dir emitted twice, or a fold // replayed, must not grow the list. mcpp::manifest::Manifest m; @@ -455,9 +440,8 @@ TEST(BuildDirectives, FoldIsIdempotentOnIncludeDirs) { FakeUsage priv; dirs::fold_private_tail(priv, m, before); dirs::fold_private_tail(priv, m, before); - EXPECT_EQ(priv.includeDirs, - (std::vector{under_root("inc")})); -} + expect(eq(priv.includeDirs, (std::vector{under_root("inc")}))); +}}; // ── Run bound ────────────────────────────────────────────────────────────── @@ -469,7 +453,7 @@ TEST(BuildDirectives, FoldIsIdempotentOnIncludeDirs) { // With a plain `int` those two would be the same value, and since 0 MEANS "no // limit", every project that never mentions the key would silently lose its // bound. That is why both levels are optionals all the way down. -TEST(BuildDirectives, RunTimeoutPrecedenceIsEnvThenManifestThenDefault) { +UTest run_timeout_precedence_is_env_then_manifest_then_default{"BuildDirectives/RunTimeoutPrecedenceIsEnvThenManifestThenDefault", [] { using ms = std::chrono::milliseconds; constexpr auto def = ms(dirs::kDefaultRunTimeoutSecs * 1000); const std::optional unset; @@ -488,15 +472,14 @@ TEST(BuildDirectives, RunTimeoutPrecedenceIsEnvThenManifestThenDefault) { { 1, 1, ms(1'000) }, }; for (auto const& r : rows) { - EXPECT_EQ(dirs::run_timeout(r.env, r.manifest), r.want) - << "env=" << (r.env ? std::to_string(*r.env) : "unset") + if (auto&& failed = expect(eq(dirs::run_timeout(r.env, r.manifest), r.want)); !failed) failed << "env=" << (r.env ? std::to_string(*r.env) : "unset") << " manifest=" << (r.manifest ? std::to_string(*r.manifest) : "unset"); } // Default: bounded. An unbounded build program is how a build hangs with // no diagnostic at all. - EXPECT_GT(dirs::run_timeout(unset, unset).count(), 0); -} + expect(gt(dirs::run_timeout(unset, unset).count(), 0)); +}}; // ── Glob inputs (#359) ───────────────────────────────────────────────────── // @@ -531,20 +514,20 @@ struct GlobTree { } // namespace -TEST(BuildDirectives, GlobFingerprintChangesWhenAFileAppearsOrDisappears) { +UTest glob_fingerprint_changes_when_a_file_appears_or_disappears{"BuildDirectives/GlobFingerprintChangesWhenAFileAppearsOrDisappears", [] { GlobTree t{"mcpp_glob_fp_membership"}; t.write("proto/a.proto", "syntax=\"proto3\";"); auto one = t.fp("proto/**/*.proto"); t.write("proto/b.proto", "syntax=\"proto3\";"); auto two = t.fp("proto/**/*.proto"); - EXPECT_NE(one, two); + expect(ne(one, two)); std::filesystem::remove(t.root / "proto/b.proto"); - EXPECT_EQ(t.fp("proto/**/*.proto"), one); -} + expect(eq(t.fp("proto/**/*.proto"), one)); +}}; -TEST(BuildDirectives, GlobFingerprintIgnoresContentSizeAndTimestamp) { +UTest glob_fingerprint_ignores_content_size_and_timestamp{"BuildDirectives/GlobFingerprintIgnoresContentSizeAndTimestamp", [] { // Contents are covered by the ordinary `rerun-if-changed` entry for that // file. Folding them in here would only add false re-runs — and mtime is // unstable across git checkout, container builds and rsync, which this @@ -557,10 +540,10 @@ TEST(BuildDirectives, GlobFingerprintIgnoresContentSizeAndTimestamp) { std::filesystem::last_write_time( t.root / "proto/a.proto", std::filesystem::file_time_type::clock::now() + std::chrono::hours(1)); - EXPECT_EQ(t.fp("proto/**/*.proto"), before); -} + expect(eq(t.fp("proto/**/*.proto"), before)); +}}; -TEST(BuildDirectives, GlobFingerprintNeverWalksTheBuildOutputTree) { +UTest glob_fingerprint_never_walks_the_build_output_tree{"BuildDirectives/GlobFingerprintNeverWalksTheBuildOutputTree", [] { // A build program writes its outputs INSIDE the project. If a wide pattern // included them the set would change on every build and the program would // re-run forever — the classic Cargo footgun. Enforced by the engine @@ -570,34 +553,34 @@ TEST(BuildDirectives, GlobFingerprintNeverWalksTheBuildOutputTree) { auto before = t.fp("**"); t.write("target/.build-mcpp/out/a.pb.cc", "generated"); t.write("target/.build-mcpp/out/a.pb.h", "generated"); - EXPECT_EQ(t.fp("**"), before); + expect(eq(t.fp("**"), before)); // .git is excluded for the same reason: it changes on every commit and // never means the build program's inputs changed. t.write(".git/HEAD", "ref: refs/heads/main"); - EXPECT_EQ(t.fp("**"), before); -} + expect(eq(t.fp("**"), before)); +}}; -TEST(BuildDirectives, GlobFingerprintIsIndependentOfDirectoryIterationOrder) { +UTest glob_fingerprint_is_independent_of_directory_iteration_order{"BuildDirectives/GlobFingerprintIsIndependentOfDirectoryIterationOrder", [] { // The set is sorted before hashing, so two trees with the same members // agree regardless of the order the platform hands them back. GlobTree a{"mcpp_glob_fp_order_a"}; GlobTree b{"mcpp_glob_fp_order_b"}; for (auto n : { "z.proto", "a.proto", "m.proto" }) a.write(std::string("proto/") + n, "x"); for (auto n : { "a.proto", "m.proto", "z.proto" }) b.write(std::string("proto/") + n, "y"); - EXPECT_EQ(a.fp("proto/**/*.proto"), b.fp("proto/**/*.proto")); -} + expect(eq(a.fp("proto/**/*.proto"), b.fp("proto/**/*.proto"))); +}}; -TEST(BuildDirectives, GlobDirectiveParsesIntoItsOwnSlot) { +UTest glob_directive_parses_into_its_own_slot{"BuildDirectives/GlobDirectiveParsesIntoItsOwnSlot", [] { auto d = parse("mcpp:rerun-if-changed-glob=proto/**/*.proto\n"); - ASSERT_EQ(d.at(dirs::Slot::RerunGlobs).size(), 1u); - EXPECT_EQ(d.at(dirs::Slot::RerunGlobs)[0], "proto/**/*.proto"); + expect(fatal(eq(d.at(dirs::Slot::RerunGlobs).size(), 1u))); + expect(eq(d.at(dirs::Slot::RerunGlobs)[0], "proto/**/*.proto")); // A re-run key is not a build input, so it must not be persisted as a `d` // record — otherwise a cache hit would replay it as one. std::ostringstream os; dirs::serialize(os, d); - EXPECT_EQ(os.str().find("proto/**"), std::string::npos) << os.str(); -} + expect(eq(os.str().find("proto/**"), std::string::npos)) << os.str(); +}}; // ── link-script ──────────────────────────────────────────────────────────── // @@ -607,17 +590,17 @@ TEST(BuildDirectives, GlobDirectiveParsesIntoItsOwnSlot) { // `link-search` emits `-L`) — so without it the one thing a user cannot write // for themselves was the one thing they had to. -TEST(BuildDirectives, LinkScriptBecomesDashTWithAnAbsolutePath) { +UTest link_script_becomes_dash_t_with_an_absolute_path{"BuildDirectives/LinkScriptBecomesDashTWithAnAbsolutePath", [] { auto d = parse("mcpp:link-script=board/link.ld\n"); auto& ld = d.at(dirs::Slot::LdFlags); - ASSERT_EQ(ld.size(), 1u); + expect(fatal(eq(ld.size(), 1u))); // Absolute, because the link runs in the build directory: a relative path // resolves against the wrong root and lld answers "cannot find linker // script link.ld" (measured). - EXPECT_EQ(ld[0], "-T " + under_root("board/link.ld")); -} + expect(eq(ld[0], "-T " + under_root("board/link.ld"))); +}}; -TEST(BuildDirectives, LinkScriptIsLinkGlobalSoItReachesTheConsumer) { +UTest link_script_is_link_global_so_it_reaches_the_consumer{"BuildDirectives/LinkScriptIsLinkGlobalSoItReachesTheConsumer", [] { // The whole point: a dependency's build.mcpp must be able to widen the // LINK (as link-lib/link-search already do) without widening the public // COMPILE interface (which include-dir deliberately cannot). @@ -627,21 +610,21 @@ TEST(BuildDirectives, LinkScriptIsLinkGlobalSoItReachesTheConsumer) { if (def.wire == "link-script") script = &def; if (def.wire == "include-dir") incdir = &def; } - ASSERT_NE(script, nullptr); - ASSERT_NE(incdir, nullptr); - EXPECT_EQ(script->scope, dirs::Scope::LinkGlobal); - EXPECT_EQ(incdir->scope, dirs::Scope::PackagePrivate); -} + expect(fatal(ne(script, nullptr))); + expect(fatal(ne(incdir, nullptr))); + expect(eq(script->scope, dirs::Scope::LinkGlobal)); + expect(eq(incdir->scope, dirs::Scope::PackagePrivate)); +}}; -TEST(BuildDirectives, LinkScriptDoesNotClaimADeclaredOutput) { +UTest link_script_does_not_claim_a_declared_output{"BuildDirectives/LinkScriptDoesNotClaimADeclaredOutput", [] { // `mustExistAfterRun` assumes the value IS a path; this row's transformed // value is `-T `, so the check would test the wrong string and // reject a script that is right there. for (auto const& def : dirs::kTable) { if (def.wire != "link-script") continue; - EXPECT_FALSE(def.mustExistAfterRun); + expect(!def.mustExistAfterRun); } -} +}}; // ── runner ───────────────────────────────────────────────────────────────── // @@ -650,7 +633,7 @@ TEST(BuildDirectives, LinkScriptDoesNotClaimADeclaredOutput) { // the emulator lives in a package payload whose path carries a home and a // version, and only a build program can compute that. -TEST(BuildDirectives, RunnerBuildsAnArgvFromRepeatedLines) { +UTest runner_builds_an_argv_from_repeated_lines{"BuildDirectives/RunnerBuildsAnArgvFromRepeatedLines", [] { // argv is an ordered list and a directive is one line = one value, so the // list is built by repetition. Order is the emission order. auto d = parse("mcpp:runner=/payload/bin/qemu-system-riscv64\n" @@ -658,54 +641,54 @@ TEST(BuildDirectives, RunnerBuildsAnArgvFromRepeatedLines) { "mcpp:runner=virt\n" "mcpp:runner=-kernel\n"); auto& r = d.at(dirs::Slot::Runner); - ASSERT_EQ(r.size(), 4u); - EXPECT_EQ(r[0], "/payload/bin/qemu-system-riscv64"); - EXPECT_EQ(r[1], "-machine"); - EXPECT_EQ(r[3], "-kernel"); -} + expect(fatal(eq(r.size(), 4u))); + expect(eq(r[0], "/payload/bin/qemu-system-riscv64")); + expect(eq(r[1], "-machine")); + expect(eq(r[3], "-kernel")); +}}; -TEST(BuildDirectives, RunnerTokensAreVerbatim) { +UTest runner_tokens_are_verbatim{"BuildDirectives/RunnerTokensAreVerbatim", [] { // Not a path to normalize: `-bios` is a token, and the producer already // resolved the executable absolutely (a bare name resolves through PATH to // a shim that dispatches against its OWNER home — measured in CI). auto d = parse("mcpp:runner=-bios\nmcpp:runner=none\n"); auto& r = d.at(dirs::Slot::Runner); - ASSERT_EQ(r.size(), 2u); - EXPECT_EQ(r[0], "-bios"); - EXPECT_EQ(r[1], "none"); -} + expect(fatal(eq(r.size(), 2u))); + expect(eq(r[0], "-bios")); + expect(eq(r[1], "none")); +}}; -TEST(BuildDirectives, RunnerHasItsOwnSlotAndScope) { +UTest runner_has_its_own_slot_and_scope{"BuildDirectives/RunnerHasItsOwnSlotAndScope", [] { const dirs::Def* runner = nullptr; const dirs::Def* script = nullptr; for (auto const& def : dirs::kTable) { if (def.wire == "runner") runner = &def; if (def.wire == "link-script") script = &def; } - ASSERT_NE(runner, nullptr); - ASSERT_NE(script, nullptr); + expect(fatal(ne(runner, nullptr))); + expect(fatal(ne(script, nullptr))); // Its own slot: putting it in LdFlags would put an emulator's argv on the // linker command line. - EXPECT_EQ(runner->slot, dirs::Slot::Runner); - EXPECT_NE(runner->slot, script->slot); + expect(eq(runner->slot, dirs::Slot::Runner)); + expect(ne(runner->slot, script->slot)); // Its own scope: RunGlobal propagates like LinkGlobal but carries an // exactly-one-provider rule that LinkGlobal must not inherit — two // dependencies' link flags concatenate correctly, two runners cannot. - EXPECT_EQ(runner->scope, dirs::Scope::RunGlobal); - EXPECT_EQ(script->scope, dirs::Scope::LinkGlobal); -} + expect(eq(runner->scope, dirs::Scope::RunGlobal)); + expect(eq(script->scope, dirs::Scope::LinkGlobal)); +}}; -TEST(BuildDirectives, RunnerLandsInBuildConfigNotInLdflags) { +UTest runner_lands_in_build_config_not_in_ldflags{"BuildDirectives/RunnerLandsInBuildConfigNotInLdflags", [] { auto d = parse("mcpp:runner=/qemu\nmcpp:runner=-kernel\n" "mcpp:link-lib=c\n"); mcpp::manifest::Manifest m; dirs::apply(m, d); - ASSERT_EQ(m.buildConfig.runner.size(), 2u); - EXPECT_EQ(m.buildConfig.runner[0], "/qemu"); + expect(fatal(eq(m.buildConfig.runner.size(), 2u))); + expect(eq(m.buildConfig.runner[0], "/qemu")); // The link line must carry the library and nothing of the runner. for (auto const& f : m.buildConfig.ldflags) - EXPECT_EQ(f.find("qemu"), std::string::npos) << f; -} + expect(eq(f.find("qemu"), std::string::npos)) << f; +}}; // ── Build-graph node decoding: `mcpp:action=` ─────────────────────────────── // @@ -716,35 +699,35 @@ TEST(BuildDirectives, RunnerLandsInBuildConfigNotInLdflags) { // matches "the field was never set" — so an action a build program submitted // before this field existed decodes exactly as it always has. -TEST(BuildDirectives, DecodeActionRoundTripsDepfile) { +UTest decode_action_round_trips_depfile{"BuildDirectives/DecodeActionRoundTripsDepfile", [] { auto d = parse( "mcpp:action={\"id\":\"shader\",\"role\":\"source\"," "\"description\":\"\",\"blocking\":false,\"depfile\":\"out/shader.spv.d\"," "\"inputs\":[],\"outputs\":[\"out/shader.spv\"]," "\"command\":[\"glslc.sh\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); - ASSERT_EQ(d.at(dirs::Slot::Actions).size(), 1u); + expect(fatal(eq(d.at(dirs::Slot::Actions).size(), 1u))); auto a = dirs::decode_action(d.at(dirs::Slot::Actions).front()); - ASSERT_TRUE(a.has_value()); - EXPECT_EQ(a->depfile, "out/shader.spv.d"); -} + expect(fatal(a.has_value())); + expect(eq(a->depfile, "out/shader.spv.d")); +}}; // THE CONTROL — and the common case, since `depfile` is optional and // `hostprogram.cppm`'s `submit()` omits the key entirely when it was never // set (byte-identical payload to before the field existed). A decoder that // required the key would reject every action submitted by that path. -TEST(BuildDirectives, DecodeActionDefaultsDepfileToEmptyWhenAbsent) { +UTest decode_action_defaults_depfile_to_empty_when_absent{"BuildDirectives/DecodeActionDefaultsDepfileToEmptyWhenAbsent", [] { auto d = parse( "mcpp:action={\"id\":\"gen\",\"role\":\"source\"," "\"description\":\"\",\"blocking\":false," "\"inputs\":[],\"outputs\":[\"out/gen.cpp\"]," "\"command\":[\"gen.sh\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); - ASSERT_EQ(d.at(dirs::Slot::Actions).size(), 1u); + expect(fatal(eq(d.at(dirs::Slot::Actions).size(), 1u))); auto a = dirs::decode_action(d.at(dirs::Slot::Actions).front()); - ASSERT_TRUE(a.has_value()); - EXPECT_EQ(a->depfile, ""); -} + expect(fatal(a.has_value())); + expect(eq(a->depfile, "")); +}}; // The overflow marker. Until 2026.9.13.1 `mcpp::action` held its lists in // fixed arrays and set the marker when a declaration did not fit; the lists @@ -752,20 +735,20 @@ TEST(BuildDirectives, DecodeActionDefaultsDepfileToEmptyWhenAbsent) { // refusal stays -- an incomplete declaration must never be used -- and the // message has to say what is now true: nothing about a buffer size, nothing // recommending a response file for a list that no longer needs one. -TEST(BuildDirectives, OverflowMarkerIsRefusedAsAllocationFailure) { +UTest overflow_marker_is_refused_as_allocation_failure{"BuildDirectives/OverflowMarkerIsRefusedAsAllocationFailure", [] { auto d = parse( "mcpp:action={\"id\":\"wide\",\"role\":\"source\"," "\"description\":\"\",\"blocking\":false,\"overflow\":true," "\"inputs\":[],\"outputs\":[\"out/a.txt\"]," "\"command\":[\"gen\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); auto err = dirs::action_error(d); - ASSERT_FALSE(err.empty()); - EXPECT_NE(err.find("could not be stored"), std::string::npos); - EXPECT_NE(err.find("out of memory"), std::string::npos); - EXPECT_EQ(err.find("fixed buffer"), std::string::npos); - EXPECT_EQ(err.find("response file"), std::string::npos); - EXPECT_EQ(err.find("did not fit"), std::string::npos); -} + expect(fatal(!err.empty())); + expect(ne(err.find("could not be stored"), std::string::npos)); + expect(ne(err.find("out of memory"), std::string::npos)); + expect(eq(err.find("fixed buffer"), std::string::npos)); + expect(eq(err.find("response file"), std::string::npos)); + expect(eq(err.find("did not fit"), std::string::npos)); +}}; // ── mcpp#702: the five action roles, and `output_dir` (`prepare` only) ───── // @@ -776,7 +759,7 @@ TEST(BuildDirectives, OverflowMarkerIsRefusedAsAllocationFailure) { // -- a cache-hit replay reaches the payload through `apply` alone, never // through `action_error` (see the epoch-3 comment in program_protocol.cppm). -TEST(BuildDirectives, DecodeActionAcceptsEachOfTheFiveRoles) { +UTest decode_action_accepts_each_of_the_five_roles{"BuildDirectives/DecodeActionAcceptsEachOfTheFiveRoles", [] { struct Row { const char* role; mcpp::manifest::BuildAction::Role want; @@ -797,65 +780,65 @@ TEST(BuildDirectives, DecodeActionAcceptsEachOfTheFiveRoles) { "\"inputs\":[],\"outputs\":[\"out/a\"]," "\"command\":[\"cmd\"],\"provides\":[],\"imports\":[],\"targets\":[]}}\n", row.role, row.extra)); - ASSERT_EQ(d.at(dirs::Slot::Actions).size(), 1u) << row.role; + expect(fatal(eq(d.at(dirs::Slot::Actions).size(), 1u))) << row.role; auto a = dirs::decode_action(d.at(dirs::Slot::Actions).front()); - ASSERT_TRUE(a.has_value()) << row.role; - EXPECT_EQ(a->role, row.want) << row.role; + expect(fatal(a.has_value())) << row.role; + expect(eq(a->role, row.want)) << row.role; } -} +}}; // The default when `role` is absent is still `source`, exactly as it was // before `prepare` and `output_dir` existed. -TEST(BuildDirectives, DecodeActionDefaultsRoleToSourceWhenAbsent) { +UTest decode_action_defaults_role_to_source_when_absent{"BuildDirectives/DecodeActionDefaultsRoleToSourceWhenAbsent", [] { auto d = parse( "mcpp:action={\"id\":\"gen\"," "\"description\":\"\",\"blocking\":false," "\"inputs\":[],\"outputs\":[\"out/gen.cpp\"]," "\"command\":[\"gen.sh\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); auto a = dirs::decode_action(d.at(dirs::Slot::Actions).front()); - ASSERT_TRUE(a.has_value()); - EXPECT_EQ(a->role, mcpp::manifest::BuildAction::Role::Source); -} + expect(fatal(a.has_value())); + expect(eq(a->role, mcpp::manifest::BuildAction::Role::Source)); +}}; -TEST(BuildDirectives, ActionErrorRefusesAnUnknownRoleAndListsTheFive) { +UTest action_error_refuses_an_unknown_role_and_lists_the_five{"BuildDirectives/ActionErrorRefusesAnUnknownRoleAndListsTheFive", [] { auto d = parse( "mcpp:action={\"id\":\"a\",\"role\":\"chek\"," "\"description\":\"\",\"blocking\":false," "\"inputs\":[],\"outputs\":[\"out/a\"]," "\"command\":[\"cmd\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); auto err = dirs::action_error(d); - ASSERT_FALSE(err.empty()); - EXPECT_NE(err.find("\"chek\""), std::string::npos) << err; + expect(fatal(!err.empty())); + expect(ne(err.find("\"chek\""), std::string::npos)) << err; for (auto const* role : {"source", "check", "object", "artifact", "prepare"}) - EXPECT_NE(err.find(role), std::string::npos) << err; -} + expect(ne(err.find(role), std::string::npos)) << err; +}}; -TEST(BuildDirectives, DecodeActionRefusesAnUnknownRoleEvenOutsideActionError) { +UTest decode_action_refuses_an_unknown_role_even_outside_action_error{"BuildDirectives/DecodeActionRefusesAnUnknownRoleEvenOutsideActionError", [] { auto d = parse( "mcpp:action={\"id\":\"a\",\"role\":\"chek\"," "\"description\":\"\",\"blocking\":false," "\"inputs\":[],\"outputs\":[\"out/a\"]," "\"command\":[\"cmd\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); - EXPECT_FALSE(dirs::decode_action(d.at(dirs::Slot::Actions).front()).has_value()); -} + expect(!(dirs::decode_action(d.at(dirs::Slot::Actions).front()).has_value())); +}}; // `output_dir` round-trips like `depfile`: present when the typed builder // set it, and read back verbatim. -TEST(BuildDirectives, DecodeActionRoundTripsOutputDir) { +UTest decode_action_round_trips_output_dir{"BuildDirectives/DecodeActionRoundTripsOutputDir", [] { auto d = parse( "mcpp:action={\"id\":\"prep\",\"role\":\"prepare\"," "\"description\":\"\",\"blocking\":false,\"output_dir\":\"out/prefix\"," "\"inputs\":[],\"outputs\":[\"out/prep.stamp\"]," "\"command\":[\"install.sh\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); auto a = dirs::decode_action(d.at(dirs::Slot::Actions).front()); - ASSERT_TRUE(a.has_value()); - EXPECT_EQ(a->outputDir, "out/prefix"); -} + expect(fatal(a.has_value())); + expect(eq(a->outputDir, "out/prefix")); +}}; // mcpp#708, protocol 13: `env` and `cwd` round-trip like `output_dir` -- // present when the typed builder set them, absent otherwise, and an action // without them decodes exactly as it did under protocol 12. -TEST(BuildDirectives, DecodeActionRoundTripsEnvAndCwd) { +UTest decode_action_round_trips_env_and_cwd{"BuildDirectives/DecodeActionRoundTripsEnvAndCwd", [] { auto d = parse( "mcpp:action={\"id\":\"gen\",\"role\":\"source\"," "\"description\":\"\",\"blocking\":false," @@ -863,11 +846,11 @@ TEST(BuildDirectives, DecodeActionRoundTripsEnvAndCwd) { "\"inputs\":[],\"outputs\":[\"out/gen.cpp\"]," "\"command\":[\"gen\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); auto a = dirs::decode_action(d.at(dirs::Slot::Actions).front()); - ASSERT_TRUE(a.has_value()); - ASSERT_EQ(a->env.size(), 2u); - EXPECT_EQ(a->env[0], "GEN_MODE=fast"); - EXPECT_EQ(a->env[1], "EMPTY="); // an empty value is a value - EXPECT_EQ(a->cwd, "third_party/gen"); + expect(fatal(a.has_value())); + expect(fatal(eq(a->env.size(), 2u))); + expect(eq(a->env[0], "GEN_MODE=fast")); + expect(eq(a->env[1], "EMPTY=")); // an empty value is a value + expect(eq(a->cwd, "third_party/gen")); auto plain = parse( "mcpp:action={\"id\":\"gen\",\"role\":\"source\"," @@ -875,41 +858,41 @@ TEST(BuildDirectives, DecodeActionRoundTripsEnvAndCwd) { "\"inputs\":[],\"outputs\":[\"out/gen.cpp\"]," "\"command\":[\"gen\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); auto b = dirs::decode_action(plain.at(dirs::Slot::Actions).front()); - ASSERT_TRUE(b.has_value()); - EXPECT_TRUE(b->env.empty()); - EXPECT_TRUE(b->cwd.empty()); -} + expect(fatal(b.has_value())); + expect(b->env.empty()); + expect(b->cwd.empty()); +}}; // An `env` entry with no name is refused, and the refusal names the entry. -TEST(BuildDirectives, AnEnvEntryWithoutANameIsRefused) { +UTest an_env_entry_without_a_name_is_refused{"BuildDirectives/AnEnvEntryWithoutANameIsRefused", [] { auto d = parse( "mcpp:action={\"id\":\"gen\",\"role\":\"source\"," "\"description\":\"\",\"blocking\":false,\"env\":[\"=oops\"]," "\"inputs\":[],\"outputs\":[\"out/gen.cpp\"]," "\"command\":[\"gen\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); - EXPECT_FALSE(dirs::decode_action(d.at(dirs::Slot::Actions).front()).has_value()); + expect(!(dirs::decode_action(d.at(dirs::Slot::Actions).front()).has_value())); const auto why = dirs::action_error(d); - EXPECT_NE(why.find("=oops"), std::string::npos) << why; - EXPECT_NE(why.find("NAME=value"), std::string::npos) << why; -} + expect(ne(why.find("=oops"), std::string::npos)) << why; + expect(ne(why.find("NAME=value"), std::string::npos)) << why; +}}; // A `prepare` action with no `output_dir` is refused: it would be a stamp // and nothing else, indistinguishable from a `check` that forgot // `blocking = true`. -TEST(BuildDirectives, PrepareActionWithNoOutputDirIsRefused) { +UTest prepare_action_with_no_output_dir_is_refused{"BuildDirectives/PrepareActionWithNoOutputDirIsRefused", [] { auto d = parse( "mcpp:action={\"id\":\"prep\",\"role\":\"prepare\"," "\"description\":\"\",\"blocking\":false," "\"inputs\":[],\"outputs\":[\"out/prep.stamp\"]," "\"command\":[\"install.sh\"],\"provides\":[],\"imports\":[],\"targets\":[]}\n"); - EXPECT_FALSE(dirs::decode_action(d.at(dirs::Slot::Actions).front()).has_value()); - EXPECT_FALSE(dirs::action_error(d).empty()); -} + expect(!(dirs::decode_action(d.at(dirs::Slot::Actions).front()).has_value())); + expect(!(dirs::action_error(d).empty())); +}}; // Cache round trip: `mcpp:action=` is persisted (Scope::GraphNode), and a // replayed `prepare` payload decodes to the same role and output_dir a fresh // run would -- the cache hit must replay both exactly. -TEST(BuildDirectives, PrepareActionSurvivesTheCacheRoundTrip) { +UTest prepare_action_survives_the_cache_round_trip{"BuildDirectives/PrepareActionSurvivesTheCacheRoundTrip", [] { // The paths are JSON strings: a Windows path's backslashes are escaped, // as the bundled module's serializer escapes them. auto json_string = [](std::string_view v) { @@ -927,12 +910,12 @@ TEST(BuildDirectives, PrepareActionSurvivesTheCacheRoundTrip) { "\"command\":[\"install.sh\"],\"provides\":[],\"imports\":[],\"targets\":[]}}", json_string(under_root("prefix")), json_string(under_root("prep.stamp"))); dirs::Directives replayed; - ASSERT_TRUE(dirs::accept_cache_record(replayed, "action", payload)); + expect(fatal(bool(dirs::accept_cache_record(replayed, "action", payload)))); auto a = dirs::decode_action(replayed.at(dirs::Slot::Actions).front()); - ASSERT_TRUE(a.has_value()); - EXPECT_EQ(a->role, mcpp::manifest::BuildAction::Role::Prepare); - EXPECT_EQ(a->outputDir, under_root("prefix")); -} + expect(fatal(a.has_value())); + expect(eq(a->role, mcpp::manifest::BuildAction::Role::Prepare)); + expect(eq(a->outputDir, under_root("prefix"))); +}}; // ── #618: a named executable's subsystem and entry ────────────────────────── // @@ -957,27 +940,27 @@ mcpp::manifest::Manifest manifest_with_gui_and_core() { } // namespace -TEST(BuildDirectives, WindowsSubsystemReachesTheNamedExecutableOnly) { +UTest windows_subsystem_reaches_the_named_executable_only{"BuildDirectives/WindowsSubsystemReachesTheNamedExecutableOnly", [] { auto d = parse("mcpp:protocol=10\n" "mcpp:windows-subsystem=gui:windows\n" "mcpp:windows-entry=gui:wWinMain\n"); - ASSERT_FALSE(dirs::protocol_error(d).has_value()); + expect(fatal(!(dirs::protocol_error(d).has_value()))); auto m = manifest_with_gui_and_core(); - ASSERT_EQ(dirs::target_directive_error(m, d), ""); + expect(fatal(eq(dirs::target_directive_error(m, d), ""))); dirs::apply(m, d); - EXPECT_EQ(m.targets[0].windowsSubsystem, "windows"); - EXPECT_EQ(m.targets[0].windowsEntry, "wWinMain"); - EXPECT_TRUE(m.targets[1].windowsSubsystem.empty()); - EXPECT_TRUE(m.targets[1].windowsEntry.empty()); + expect(eq(m.targets[0].windowsSubsystem, "windows")); + expect(eq(m.targets[0].windowsEntry, "wWinMain")); + expect(m.targets[1].windowsSubsystem.empty()); + expect(m.targets[1].windowsEntry.empty()); // Nothing reaches a flag channel another target or a consumer reads. - EXPECT_TRUE(m.buildConfig.ldflags.empty()); - EXPECT_TRUE(m.buildConfig.cxxflags.empty()); -} + expect(m.buildConfig.ldflags.empty()); + expect(m.buildConfig.cxxflags.empty()); +}}; // #622 A3: `windows-subsystem`/`windows-entry` reach an `app` target exactly // as they reach a `bin` one -- `directives.cppm`'s `apply` and // `target_directive_error` ask `is_program()`, not `kind == Binary`. -TEST(BuildDirectives, WindowsSubsystemReachesAnApplicationTargetToo) { +UTest windows_subsystem_reaches_an_application_target_too{"BuildDirectives/WindowsSubsystemReachesAnApplicationTargetToo", [] { mcpp::manifest::Manifest m; m.package.name = "app"; mcpp::manifest::Target myapp; @@ -988,23 +971,23 @@ TEST(BuildDirectives, WindowsSubsystemReachesAnApplicationTargetToo) { auto d = parse("mcpp:protocol=10\n" "mcpp:windows-subsystem=myapp:windows\n" "mcpp:windows-entry=myapp:wWinMain\n"); - ASSERT_EQ(dirs::target_directive_error(m, d), ""); + expect(fatal(eq(dirs::target_directive_error(m, d), ""))); dirs::apply(m, d); - EXPECT_EQ(m.targets[0].windowsSubsystem, "windows"); - EXPECT_EQ(m.targets[0].windowsEntry, "wWinMain"); -} + expect(eq(m.targets[0].windowsSubsystem, "windows")); + expect(eq(m.targets[0].windowsEntry, "wWinMain")); +}}; -TEST(BuildDirectives, WindowsSubsystemRowsHaveTheTargetLinkScope) { +UTest windows_subsystem_rows_have_the_target_link_scope{"BuildDirectives/WindowsSubsystemRowsHaveTheTargetLinkScope", [] { for (auto wire : {"windows-subsystem", "windows-entry"}) { auto def = dirs::find_by_wire(wire); - ASSERT_NE(def, nullptr) << wire; - EXPECT_EQ(def->scope, dirs::Scope::TargetLink) << wire; - EXPECT_EQ(def->sinceProtocol, 10) << wire; - EXPECT_FALSE(def->tag.empty()) << wire; + expect(fatal(ne(def, nullptr))) << wire; + expect(eq(def->scope, dirs::Scope::TargetLink)) << wire; + expect(eq(def->sinceProtocol, 10)) << wire; + expect(!(def->tag.empty())) << wire; } -} +}}; -TEST(BuildDirectives, WindowsSubsystemRefusesWhatApplyCannotHonour) { +UTest windows_subsystem_refuses_what_apply_cannot_honour{"BuildDirectives/WindowsSubsystemRefusesWhatApplyCannotHonour", [] { const std::pair cases[] = { {"mcpp:windows-subsystem=windows\n", "which is not `:`"}, {"mcpp:windows-subsystem=gui:\n", "which is not `:`"}, @@ -1020,21 +1003,21 @@ TEST(BuildDirectives, WindowsSubsystemRefusesWhatApplyCannotHonour) { for (auto [text, expected] : cases) { auto d = parse(std::format("mcpp:protocol=10\n{}", text)); auto err = dirs::target_directive_error(manifest_with_gui_and_core(), d); - EXPECT_NE(err.find(expected), std::string::npos) << text << " -> " << err; + if (auto&& failed = expect(ne(err.find(expected), std::string::npos)); !failed) failed << text << " -> " << err; } -} +}}; -TEST(BuildDirectives, WindowsSubsystemThatContradictsTheManifestIsRefused) { +UTest windows_subsystem_that_contradicts_the_manifest_is_refused{"BuildDirectives/WindowsSubsystemThatContradictsTheManifestIsRefused", [] { auto m = manifest_with_gui_and_core(); m.targets[0].windowsSubsystem = "console"; auto d = parse("mcpp:protocol=10\nmcpp:windows-subsystem=gui:windows\n"); auto err = dirs::target_directive_error(m, d); - EXPECT_NE(err.find("[targets.gui] windows_subsystem = \"console\""), std::string::npos) + expect(ne(err.find("[targets.gui] windows_subsystem = \"console\""), std::string::npos)) << err; // Restating the manifest's own value is not a contradiction. m.targets[0].windowsSubsystem = "windows"; - EXPECT_EQ(dirs::target_directive_error(m, d), ""); -} + expect(eq(dirs::target_directive_error(m, d), "")); +}}; // ── #622 A4: `mcpp::deploy(from, to)` ─────────────────────────────────────── // @@ -1056,30 +1039,30 @@ mcpp::manifest::Manifest manifest_named(std::string name) { } // namespace -TEST(BuildDirectives, DeployRowIsProtocolElevenWithLinkGlobalScopeAndATag) { +UTest deploy_row_is_protocol_eleven_with_link_global_scope_and_a_tag{"BuildDirectives/DeployRowIsProtocolElevenWithLinkGlobalScopeAndATag", [] { auto def = dirs::find_by_wire("deploy"); - ASSERT_NE(def, nullptr); - EXPECT_EQ(def->scope, dirs::Scope::LinkGlobal); - EXPECT_EQ(def->sinceProtocol, 11); - EXPECT_FALSE(def->tag.empty()); - EXPECT_EQ(dirs::kProtocolVersion, 18); -} - -TEST(BuildDirectives, ProtocolEighteenIsAcceptedAndNineteenIsNot) { + expect(fatal(ne(def, nullptr))); + expect(eq(def->scope, dirs::Scope::LinkGlobal)); + expect(eq(def->sinceProtocol, 11)); + expect(!(def->tag.empty())); + expect(eq(dirs::kProtocolVersion, 18)); +}}; + +UTest protocol_eighteen_is_accepted_and_nineteen_is_not{"BuildDirectives/ProtocolEighteenIsAcceptedAndNineteenIsNot", [] { auto ok = parse("mcpp:protocol=18\n"); - EXPECT_FALSE(dirs::protocol_error(ok).has_value()); + expect(!(dirs::protocol_error(ok).has_value())); auto no = parse("mcpp:protocol=19\n"); - EXPECT_TRUE(dirs::protocol_error(no).has_value()); -} + expect(dirs::protocol_error(no).has_value()); +}}; // SPEC-013 §4.1: the platform's fields a program states (protocol 17) become a // platform statement at the build-program tier; malformed lines are refused. -TEST(BuildDirectives, PlatformFieldsAreProtocolSeventeenAndReachTheStatement) { +UTest platform_fields_are_protocol_seventeen_and_reach_the_statement{"BuildDirectives/PlatformFieldsAreProtocolSeventeenAndReachTheStatement", [] { for (auto wire : {"link-driver", "link-input", "artifact-name", "seal", "run-host"}) { auto def = dirs::find_by_wire(wire); - ASSERT_NE(def, nullptr) << wire; - EXPECT_EQ(def->sinceProtocol, 17) << wire; - EXPECT_FALSE(def->tag.empty()) << wire; + expect(fatal(ne(def, nullptr))) << wire; + expect(eq(def->sinceProtocol, 17)) << wire; + expect(!(def->tag.empty())) << wire; } mcpp::manifest::Manifest m; auto d = parse("mcpp:link-driver=lld\n" @@ -1091,116 +1074,116 @@ TEST(BuildDirectives, PlatformFieldsAreProtocolSeventeenAndReachTheStatement) { "mcpp:seal=0:okld\nmcpp:seal=0:note\nmcpp:seal=0:{}\n" "mcpp:seal=1:okld\nmcpp:seal=1:check\nmcpp:seal=1:{}\n" "mcpp:run-host=same-arch\n"); - EXPECT_EQ(dirs::target_directive_error(m, d), ""); + expect(eq(dirs::target_directive_error(m, d), "")); dirs::apply(m, d); auto const& s = m.buildConfig.programPlatform; - EXPECT_EQ(s.link.driver.value, "lld"); - ASSERT_EQ(s.link.inputs.size(), 2u); - EXPECT_EQ(s.link.inputs[0].as, "notes.S"); - EXPECT_EQ(s.link.inputs[0].from, (std::vector{"okld", "note-source"})); - EXPECT_EQ(s.link.inputs[1].file, "/opt/crt0.o"); - EXPECT_EQ(s.link.exe.value, "{name}.oke"); - EXPECT_EQ(s.link.shared.value, "lib{name}.okso"); - ASSERT_EQ(s.link.seal.size(), 2u); - EXPECT_EQ(s.link.seal[1], (std::vector{"okld", "check", "{}"})); - EXPECT_EQ(s.runner.host, "same-arch"); - EXPECT_EQ(s.link.driver.origin.tier, mcpp::manifest::platform::Tier::Program); + expect(eq(s.link.driver.value, "lld")); + expect(fatal(eq(s.link.inputs.size(), 2u))); + expect(eq(s.link.inputs[0].as, "notes.S")); + expect(eq(s.link.inputs[0].from, (std::vector{"okld", "note-source"}))); + expect(eq(s.link.inputs[1].file, "/opt/crt0.o")); + expect(eq(s.link.exe.value, "{name}.oke")); + expect(eq(s.link.shared.value, "lib{name}.okso")); + expect(fatal(eq(s.link.seal.size(), 2u))); + expect(eq(s.link.seal[1], (std::vector{"okld", "check", "{}"}))); + expect(eq(s.runner.host, "same-arch")); + expect(eq(s.link.driver.origin.tier, mcpp::manifest::platform::Tier::Program)); for (auto bad : {"mcpp:link-driver=gold\n", "mcpp:link-input=from:x\n", "mcpp:artifact-name=exe:lib{name}\n", "mcpp:artifact-name=dll:{name}\n", "mcpp:artifact-name=shared:lib/{name}.okso\n", "mcpp:seal=x:okld\n", "mcpp:run-host=far\n"}) - EXPECT_NE(dirs::target_directive_error(m, parse(bad)), "") << bad; -} + expect(ne(dirs::target_directive_error(m, parse(bad)), "")) << bad; +}}; // Design 2026-10-26 §24.3: a board's image entry symbol reaches the manifest's // link intent; a non-symbol and two different symbols are refused. -TEST(BuildDirectives, LinkStartIsProtocolSixteenAndReachesTheLinkIntent) { +UTest link_start_is_protocol_sixteen_and_reaches_the_link_intent{"BuildDirectives/LinkStartIsProtocolSixteenAndReachesTheLinkIntent", [] { auto def = dirs::find_by_wire("link-start"); - ASSERT_NE(def, nullptr); - EXPECT_EQ(def->scope, dirs::Scope::LinkGlobal); - EXPECT_EQ(def->sinceProtocol, 16); + expect(fatal(ne(def, nullptr))); + expect(eq(def->scope, dirs::Scope::LinkGlobal)); + expect(eq(def->sinceProtocol, 16)); mcpp::manifest::Manifest m; auto d = parse("mcpp:link-start=_reset\n"); - EXPECT_EQ(dirs::target_directive_error(m, d), ""); + expect(eq(dirs::target_directive_error(m, d), "")); dirs::apply(m, d); - EXPECT_EQ(m.runtimeConfig.linkIntent.start, "_reset"); - EXPECT_NE(dirs::target_directive_error(m, parse("mcpp:link-start=1bad\n")), ""); - EXPECT_NE(dirs::target_directive_error( - m, parse("mcpp:link-start=_reset\nmcpp:link-start=_start\n")), ""); -} + expect(eq(m.runtimeConfig.linkIntent.start, "_reset")); + expect(ne(dirs::target_directive_error(m, parse("mcpp:link-start=1bad\n")), "")); + expect(ne(dirs::target_directive_error( + m, parse("mcpp:link-start=_reset\nmcpp:link-start=_start\n")), "")); +}}; -TEST(BuildDirectives, DeployWithAnAbsoluteFromYieldsOneEntryOnTheManifest) { +UTest deploy_with_an_absolute_from_yields_one_entry_on_the_manifest{"BuildDirectives/DeployWithAnAbsoluteFromYieldsOneEntryOnTheManifest", [] { const std::string from = under_root("gen/x.bin"); auto d = parse(std::format("mcpp:deploy={}\tres\n", from)); auto m = manifest_named("app"); - ASSERT_EQ(dirs::deploy_directive_error(m, d), ""); + expect(fatal(eq(dirs::deploy_directive_error(m, d), ""))); dirs::apply(m, d); - ASSERT_EQ(m.runtimeConfig.linkIntent.deploy.size(), 1u); - EXPECT_EQ(m.runtimeConfig.linkIntent.deploy[0].from.string(), from); - EXPECT_EQ(m.runtimeConfig.linkIntent.deploy[0].to, "res"); -} + expect(fatal(eq(m.runtimeConfig.linkIntent.deploy.size(), 1u))); + expect(eq(m.runtimeConfig.linkIntent.deploy[0].from.string(), from)); + expect(eq(m.runtimeConfig.linkIntent.deploy[0].to, "res")); +}}; // The manifest-sourced `from` rule (`deploy_path_problem`) refuses an absolute // path. A directive's `from` is not run through that check at all: it is // resolved to an absolute path by the table's own transform instead, because // it may be an action's own declared output (#622 A4). This is the case that // rule cannot see and must not reject. -TEST(BuildDirectives, DeployFromIsResolvedToAbsoluteEvenWhenTheWireValueAlreadyWasOne) { +UTest deploy_from_is_resolved_to_absolute_even_when_the_wire_value_already_was_one{"BuildDirectives/DeployFromIsResolvedToAbsoluteEvenWhenTheWireValueAlreadyWasOne", [] { const std::string absFrom = under_root("out/gen/res.bin"); auto d = parse(std::format("mcpp:deploy={}\tapp.resources\n", absFrom)); auto m = manifest_named("app"); - EXPECT_EQ(dirs::deploy_directive_error(m, d), ""); + expect(eq(dirs::deploy_directive_error(m, d), "")); dirs::apply(m, d); - ASSERT_EQ(m.runtimeConfig.linkIntent.deploy.size(), 1u); - EXPECT_EQ(m.runtimeConfig.linkIntent.deploy[0].from.string(), absFrom); -} + expect(fatal(eq(m.runtimeConfig.linkIntent.deploy.size(), 1u))); + expect(eq(m.runtimeConfig.linkIntent.deploy[0].from.string(), absFrom)); +}}; // A relative `from` resolves against the package root, exactly as // `include-dir`'s AbsPath case does — the directive never leaves a relative // path for a later stage to guess the base of. -TEST(BuildDirectives, DeployFromRelativeToTheRootIsMadeAbsolute) { +UTest deploy_from_relative_to_the_root_is_made_absolute{"BuildDirectives/DeployFromRelativeToTheRootIsMadeAbsolute", [] { auto d = parse("mcpp:deploy=gen/x.bin\tres\n"); auto m = manifest_named("app"); - ASSERT_EQ(dirs::deploy_directive_error(m, d), ""); + expect(fatal(eq(dirs::deploy_directive_error(m, d), ""))); dirs::apply(m, d); - ASSERT_EQ(m.runtimeConfig.linkIntent.deploy.size(), 1u); - EXPECT_EQ(m.runtimeConfig.linkIntent.deploy[0].from.string(), under_root("gen/x.bin")); -} + expect(fatal(eq(m.runtimeConfig.linkIntent.deploy.size(), 1u))); + expect(eq(m.runtimeConfig.linkIntent.deploy[0].from.string(), under_root("gen/x.bin"))); +}}; // The negative direction (#622 A4, and rule 8 of the design record): a `to` // `apply` cannot honour is refused BEFORE apply, naming the directive and the // package -- not only the path problem, which `deploy_path_problem` already // states on its own. -TEST(BuildDirectives, DeployToDotDotIsRefusedNamingTheDirectiveAndThePackage) { +UTest deploy_to_dot_dot_is_refused_naming_the_directive_and_the_package{"BuildDirectives/DeployToDotDotIsRefusedNamingTheDirectiveAndThePackage", [] { auto d = parse(std::format("mcpp:deploy={}\t../x\n", under_root("gen/x.bin"))); auto m = manifest_named("widget"); auto err = dirs::deploy_directive_error(m, d); - EXPECT_NE(err.find("deploy"), std::string::npos) << err; - EXPECT_NE(err.find("widget"), std::string::npos) << err; - EXPECT_NE(err.find("`.` or `..` component"), std::string::npos) << err; -} + expect(ne(err.find("deploy"), std::string::npos)) << err; + expect(ne(err.find("widget"), std::string::npos)) << err; + expect(ne(err.find("`.` or `..` component"), std::string::npos)) << err; +}}; -TEST(BuildDirectives, DeployWithAMalformedWireValueIsRefused) { +UTest deploy_with_a_malformed_wire_value_is_refused{"BuildDirectives/DeployWithAMalformedWireValueIsRefused", [] { auto d = parse("mcpp:deploy=onlyfromnotab\n"); auto m = manifest_named("app"); auto err = dirs::deploy_directive_error(m, d); - EXPECT_NE(err.find("deploy"), std::string::npos) << err; - EXPECT_NE(err.find("not `"), std::string::npos) << err; -} + expect(ne(err.find("deploy"), std::string::npos)) << err; + expect(ne(err.find("not `"), std::string::npos)) << err; +}}; // A `to` that IS honourable does not stop the build, and the collected entry // is the only thing `apply` touches -- no flag channel gains anything, unlike // nothing here (deploy is not a compile/link flag). -TEST(BuildDirectives, DeployReachesOnlyTheRuntimeDeployListNotAnyFlagChannel) { +UTest deploy_reaches_only_the_runtime_deploy_list_not_any_flag_channel{"BuildDirectives/DeployReachesOnlyTheRuntimeDeployListNotAnyFlagChannel", [] { auto d = parse(std::format("mcpp:deploy={}\t.\n", under_root("gen/x.bin"))); auto m = manifest_named("app"); - ASSERT_EQ(dirs::deploy_directive_error(m, d), ""); + expect(fatal(eq(dirs::deploy_directive_error(m, d), ""))); dirs::apply(m, d); - ASSERT_EQ(m.runtimeConfig.linkIntent.deploy.size(), 1u); - EXPECT_EQ(m.runtimeConfig.linkIntent.deploy[0].to, "."); - EXPECT_TRUE(m.buildConfig.ldflags.empty()); - EXPECT_TRUE(m.buildConfig.cxxflags.empty()); -} + expect(fatal(eq(m.runtimeConfig.linkIntent.deploy.size(), 1u))); + expect(eq(m.runtimeConfig.linkIntent.deploy[0].to, ".")); + expect(m.buildConfig.ldflags.empty()); + expect(m.buildConfig.cxxflags.empty()); +}}; // ── v12: `mcpp::runtime_search_dir(dir)` ──────────────────────────────────── // @@ -1212,97 +1195,96 @@ TEST(BuildDirectives, DeployReachesOnlyTheRuntimeDeployListNotAnyFlagChannel) { // the RUNPATH rendering, `mcpp pack`'s closure search) see a // directive-declared entry without any of them changing. -TEST(BuildDirectives, RuntimeSearchDirRowIsProtocolTwelveWithLinkGlobalScopeAndATag) { +UTest runtime_search_dir_row_is_protocol_twelve_with_link_global_scope_and_a_tag{"BuildDirectives/RuntimeSearchDirRowIsProtocolTwelveWithLinkGlobalScopeAndATag", [] { auto def = dirs::find_by_wire("runtime-search-dir"); - ASSERT_NE(def, nullptr); - EXPECT_EQ(def->scope, dirs::Scope::LinkGlobal); - EXPECT_EQ(def->sinceProtocol, 12); - EXPECT_FALSE(def->tag.empty()); - EXPECT_EQ(def->transform, dirs::Transform::AbsPath); -} + expect(fatal(ne(def, nullptr))); + expect(eq(def->scope, dirs::Scope::LinkGlobal)); + expect(eq(def->sinceProtocol, 12)); + expect(!(def->tag.empty())); + expect(eq(def->transform, dirs::Transform::AbsPath)); +}}; // The manifest key's own field, not a new one -- this is the whole point of // the design: no downstream consumer has to learn a second field exists. NOT // `RuntimeConfig::libraryDirs`, the retiring vector #702 as first written // joined by mistake (design §5.3 point 1). -TEST(BuildDirectives, RuntimeSearchDirJoinsLinkIntentDirectlyNotTheLegacyVector) { +UTest runtime_search_dir_joins_link_intent_directly_not_the_legacy_vector{"BuildDirectives/RuntimeSearchDirJoinsLinkIntentDirectlyNotTheLegacyVector", [] { auto d = parse("mcpp:runtime-search-dir=rtlib\n"); mcpp::manifest::Manifest m; dirs::apply(m, d); - ASSERT_EQ(m.runtimeConfig.linkIntent.runtimeSearchDirs.size(), 1u); - EXPECT_EQ(m.runtimeConfig.linkIntent.runtimeSearchDirs[0].string(), under_root("rtlib")); - EXPECT_TRUE(m.runtimeConfig.libraryDirs.empty()); -} + expect(fatal(eq(m.runtimeConfig.linkIntent.runtimeSearchDirs.size(), 1u))); + expect(eq(m.runtimeConfig.linkIntent.runtimeSearchDirs[0].string(), under_root("rtlib"))); + expect(m.runtimeConfig.libraryDirs.empty()); +}}; // Relative resolves against the package root, exactly as `include-dir`'s // AbsPath case and `deploy`'s `from` do -- the directive never leaves a // relative path for a later stage to guess the base of. -TEST(BuildDirectives, RuntimeSearchDirRelativeToTheRootIsMadeAbsolute) { +UTest runtime_search_dir_relative_to_the_root_is_made_absolute{"BuildDirectives/RuntimeSearchDirRelativeToTheRootIsMadeAbsolute", [] { auto d = parse("mcpp:runtime-search-dir=vendor/qt/bin\n"); mcpp::manifest::Manifest m; dirs::apply(m, d); - ASSERT_EQ(m.runtimeConfig.linkIntent.runtimeSearchDirs.size(), 1u); - EXPECT_EQ(m.runtimeConfig.linkIntent.runtimeSearchDirs[0].string(), - under_root("vendor/qt/bin")); -} + expect(fatal(eq(m.runtimeConfig.linkIntent.runtimeSearchDirs.size(), 1u))); + expect(eq(m.runtimeConfig.linkIntent.runtimeSearchDirs[0].string(), under_root("vendor/qt/bin"))); +}}; -TEST(BuildDirectives, RuntimeSearchDirAcceptsAnAlreadyAbsoluteValue) { +UTest runtime_search_dir_accepts_an_already_absolute_value{"BuildDirectives/RuntimeSearchDirAcceptsAnAlreadyAbsoluteValue", [] { const std::string abs = under_root("prefix/lib"); auto d = parse(std::format("mcpp:runtime-search-dir={}\n", abs)); mcpp::manifest::Manifest m; dirs::apply(m, d); - ASSERT_EQ(m.runtimeConfig.linkIntent.runtimeSearchDirs.size(), 1u); - EXPECT_EQ(m.runtimeConfig.linkIntent.runtimeSearchDirs[0].string(), abs); -} + expect(fatal(eq(m.runtimeConfig.linkIntent.runtimeSearchDirs.size(), 1u))); + expect(eq(m.runtimeConfig.linkIntent.runtimeSearchDirs[0].string(), abs)); +}}; // Reaches only the run-time search field -- no compile or link flag channel // gains anything, unlike `link-search`, which shares the manifest's -L/-L // distinction. -TEST(BuildDirectives, RuntimeSearchDirReachesNoFlagChannel) { +UTest runtime_search_dir_reaches_no_flag_channel{"BuildDirectives/RuntimeSearchDirReachesNoFlagChannel", [] { auto d = parse("mcpp:runtime-search-dir=rtlib\n"); mcpp::manifest::Manifest m; dirs::apply(m, d); - EXPECT_TRUE(m.buildConfig.ldflags.empty()); - EXPECT_TRUE(m.buildConfig.cxxflags.empty()); - EXPECT_TRUE(m.runtimeConfig.linkIntent.linkLibraryDirs.empty()); -} + expect(m.buildConfig.ldflags.empty()); + expect(m.buildConfig.cxxflags.empty()); + expect(m.runtimeConfig.linkIntent.linkLibraryDirs.empty()); +}}; // Multiple directives accumulate, in emission order, the same as every other // repeated directive in this table. -TEST(BuildDirectives, RuntimeSearchDirDirectivesAccumulate) { +UTest runtime_search_dir_directives_accumulate{"BuildDirectives/RuntimeSearchDirDirectivesAccumulate", [] { auto d = parse("mcpp:runtime-search-dir=a\n" "mcpp:runtime-search-dir=b\n"); mcpp::manifest::Manifest m; dirs::apply(m, d); - ASSERT_EQ(m.runtimeConfig.linkIntent.runtimeSearchDirs.size(), 2u); - EXPECT_EQ(m.runtimeConfig.linkIntent.runtimeSearchDirs[0].string(), under_root("a")); - EXPECT_EQ(m.runtimeConfig.linkIntent.runtimeSearchDirs[1].string(), under_root("b")); -} + expect(fatal(eq(m.runtimeConfig.linkIntent.runtimeSearchDirs.size(), 2u))); + expect(eq(m.runtimeConfig.linkIntent.runtimeSearchDirs[0].string(), under_root("a"))); + expect(eq(m.runtimeConfig.linkIntent.runtimeSearchDirs[1].string(), under_root("b"))); +}}; // Persisted like `deploy`, `warning` and `pack-format`: the cache tag is // non-empty and round-trips through the same table-driven serialize/ // accept_cache_record pair, so a build.mcpp CACHE HIT replays the directive // rather than losing it on every build after the first. -TEST(BuildDirectives, RuntimeSearchDirIsPersistedAndRoundTrips) { +UTest runtime_search_dir_is_persisted_and_round_trips{"BuildDirectives/RuntimeSearchDirIsPersistedAndRoundTrips", [] { auto d = parse("mcpp:runtime-search-dir=rtlib\n"); std::ostringstream os; dirs::serialize(os, d); - EXPECT_NE(os.str().find("d runtime-search-dir "), std::string::npos) << os.str(); + expect(ne(os.str().find("d runtime-search-dir "), std::string::npos)) << os.str(); dirs::Directives replayed; - ASSERT_TRUE(dirs::accept_cache_record(replayed, "runtime-search-dir", - under_root("rtlib"))); + expect(fatal(bool(dirs::accept_cache_record(replayed, "runtime-search-dir", + under_root("rtlib"))))); mcpp::manifest::Manifest m; dirs::apply(m, replayed); - ASSERT_EQ(m.runtimeConfig.linkIntent.runtimeSearchDirs.size(), 1u); - EXPECT_EQ(m.runtimeConfig.linkIntent.runtimeSearchDirs[0].string(), under_root("rtlib")); -} + expect(fatal(eq(m.runtimeConfig.linkIntent.runtimeSearchDirs.size(), 1u))); + expect(eq(m.runtimeConfig.linkIntent.runtimeSearchDirs[0].string(), under_root("rtlib"))); +}}; -TEST(BuildDirectives, OptionalActionProgressDefaultsOffAndRejectsWrongType) { +UTest optional_action_progress_defaults_off_and_rejects_wrong_type{"BuildDirectives/OptionalActionProgressDefaultsOffAndRejectsWrongType", [] { const std::string prefix=R"({"id":"work","role":"object","command":["worker"],"outputs":["result.o"])"; const auto old=dirs::decode_action(prefix+"}"); - ASSERT_TRUE(old); EXPECT_FALSE(old->progress); + expect(fatal(bool(old))); expect(!old->progress); const auto opted=dirs::decode_action(prefix+R"(,"progress":true})"); - ASSERT_TRUE(opted); EXPECT_TRUE(opted->progress); - EXPECT_FALSE(dirs::decode_action(prefix+R"(,"progress":"true"})")); -} + expect(fatal(bool(opted))); expect(bool(opted->progress)); + expect(!(dirs::decode_action(prefix+R"(,"progress":"true"})"))); +}}; diff --git a/tests/unit/test_build_flags.cpp b/tests/unit/test_build_flags.cpp index 31948e14b..a3eb7cc86 100644 --- a/tests/unit/test_build_flags.cpp +++ b/tests/unit/test_build_flags.cpp @@ -1,12 +1,14 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.flags; import mcpp.build.distribution; import mcpp.modgraph.scanner; import mcpp.build.ninja; import mcpp.targetside; +using namespace mcpplibs::mtest; + namespace { struct Tmp { @@ -36,64 +38,64 @@ void touch(const std::filesystem::path& p) { constexpr bool kDynamic = false; // staticLink arg constexpr bool kStatic = true; -TEST(BuildFlagsAtomic, EmittedWhenLibatomicSharedPresent) { +UTest emitted_when_libatomic_shared_present{"BuildFlagsAtomic/EmittedWhenLibatomicSharedPresent", [] { Tmp dir; touch(dir.path / "libatomic.so"); auto flag = mcpp::build::atomic_link_flag({dir.path}, kDynamic); - EXPECT_NE(flag.find("-latomic"), std::string::npos); - EXPECT_NE(flag.find("--as-needed"), std::string::npos); -} + expect(ne(flag.find("-latomic"), std::string::npos)); + expect(ne(flag.find("--as-needed"), std::string::npos)); +}}; -TEST(BuildFlagsAtomic, EmittedWhenLibatomicArchivePresent) { +UTest emitted_when_libatomic_archive_present{"BuildFlagsAtomic/EmittedWhenLibatomicArchivePresent", [] { Tmp dir; touch(dir.path / "libatomic.a"); auto flag = mcpp::build::atomic_link_flag({dir.path}, kDynamic); - EXPECT_NE(flag.find("-latomic"), std::string::npos); -} + expect(ne(flag.find("-latomic"), std::string::npos)); +}}; -TEST(BuildFlagsAtomic, EmptyWhenLibatomicAbsent) { +UTest empty_when_libatomic_absent{"BuildFlagsAtomic/EmptyWhenLibatomicAbsent", [] { Tmp dir; touch(dir.path / "libc++.so.1"); // some other lib, but no libatomic auto flag = mcpp::build::atomic_link_flag({dir.path}, kDynamic); - EXPECT_TRUE(flag.empty()) << "got: " << flag; -} + expect(flag.empty()) << "got: " << flag; +}}; // `-latomic` resolves only `libatomic.so` / `libatomic.a` — a bare // soname-versioned `libatomic.so.1` (no dev symlink, no archive) is NOT // link-resolvable, so the flag must stay empty or the link breaks with // "cannot find -latomic". -TEST(BuildFlagsAtomic, EmptyWhenOnlySonameVersionedPresent) { +UTest empty_when_only_soname_versioned_present{"BuildFlagsAtomic/EmptyWhenOnlySonameVersionedPresent", [] { Tmp dir; touch(dir.path / "libatomic.so.1"); touch(dir.path / "libatomic.so.1.2.0"); auto flag = mcpp::build::atomic_link_flag({dir.path}, kDynamic); - EXPECT_TRUE(flag.empty()) << "got: " << flag; -} + expect(flag.empty()) << "got: " << flag; +}}; -TEST(BuildFlagsAtomic, ScansAllDirsNotJustFirst) { +UTest scans_all_dirs_not_just_first{"BuildFlagsAtomic/ScansAllDirsNotJustFirst", [] { Tmp a, b; touch(b.path / "libatomic.so"); // link-resolvable, in a later dir auto flag = mcpp::build::atomic_link_flag({a.path, b.path}, kDynamic); - EXPECT_NE(flag.find("-latomic"), std::string::npos); -} + expect(ne(flag.find("-latomic"), std::string::npos)); +}}; // Cross-platform / cross-linkage: a full-static link (`-static`, e.g. musl // targets) resolves `-latomic` only from `libatomic.a`. A lone `libatomic.so` // is not usable, so the flag must stay empty under static linkage to avoid // "cannot find -latomic". -TEST(BuildFlagsAtomic, StaticLinkEmptyWhenOnlySharedPresent) { +UTest static_link_empty_when_only_shared_present{"BuildFlagsAtomic/StaticLinkEmptyWhenOnlySharedPresent", [] { Tmp dir; touch(dir.path / "libatomic.so"); auto flag = mcpp::build::atomic_link_flag({dir.path}, kStatic); - EXPECT_TRUE(flag.empty()) << "got: " << flag; -} + expect(flag.empty()) << "got: " << flag; +}}; -TEST(BuildFlagsAtomic, StaticLinkEmittedWhenArchivePresent) { +UTest static_link_emitted_when_archive_present{"BuildFlagsAtomic/StaticLinkEmittedWhenArchivePresent", [] { Tmp dir; touch(dir.path / "libatomic.a"); auto flag = mcpp::build::atomic_link_flag({dir.path}, kStatic); - EXPECT_NE(flag.find("-latomic"), std::string::npos); -} + expect(ne(flag.find("-latomic"), std::string::npos)); +}}; // mcpp#226: normalize_include_flags generalizes the old -I-only // absolutize_include_flags to the whole include/lib-search-path family, in @@ -105,7 +107,7 @@ TEST(BuildFlagsAtomic, StaticLinkEmittedWhenArchivePresent) { // slashes keeps them on MSVC, and the old expectation `(root / "hdr").string()` // was itself the mixed `"/proj\hdr"` shape this family of bugs produced. // make_preferred() makes the expectation platform-correct on both sides. -TEST(BuildFlags, NormalizeIncludeFlagsRewritesFullIncludeFamily) { +UTest normalize_include_flags_rewrites_full_include_family{"BuildFlags/NormalizeIncludeFlagsRewritesFullIncludeFamily", [] { std::filesystem::path root = "/proj"; auto expected = [](const std::filesystem::path& p) { auto n = p; @@ -118,18 +120,18 @@ TEST(BuildFlags, NormalizeIncludeFlagsRewritesFullIncludeFamily) { mcpp::modgraph::normalize_include_flags(root, flags); - ASSERT_EQ(flags.size(), 5u); - EXPECT_EQ(flags[0], "-I" + expected(root / "hdr")); - EXPECT_EQ(flags[1], "-iquote" + expected(root / "hdr")); - EXPECT_EQ(flags[2], "-isystem"); // prefix itself untouched - EXPECT_EQ(flags[3], expected(root / "hdr")); // separated element rewritten - EXPECT_EQ(flags[4], "-idirafter" + expected(root / "hdr")); -} + expect(fatal(eq(flags.size(), 5u))); + expect(eq(flags[0], "-I" + expected(root / "hdr"))); + expect(eq(flags[1], "-iquote" + expected(root / "hdr"))); + expect(eq(flags[2], "-isystem")); // prefix itself untouched + expect(eq(flags[3], expected(root / "hdr"))); // separated element rewritten + expect(eq(flags[4], "-idirafter" + expected(root / "hdr"))); +}}; // Absolute paths and root-relative spellings are left alone (matches the // pre-#226 -I behavior), for both the joined and separated forms — only the // separator spelling is normalized to native (#390; a no-op on POSIX). -TEST(BuildFlags, NormalizeIncludeFlagsLeavesAbsolutePathsAlone) { +UTest normalize_include_flags_leaves_absolute_paths_alone{"BuildFlags/NormalizeIncludeFlagsLeavesAbsolutePathsAlone", [] { std::filesystem::path root = "/proj"; auto expected = [](const std::filesystem::path& p) { auto n = p; @@ -142,32 +144,32 @@ TEST(BuildFlags, NormalizeIncludeFlagsLeavesAbsolutePathsAlone) { mcpp::modgraph::normalize_include_flags(root, flags); - EXPECT_EQ(flags[0], "-I" + expected("/abs/hdr")); - EXPECT_EQ(flags[1], "-isystem"); - EXPECT_EQ(flags[2], expected("/abs/hdr")); - EXPECT_EQ(flags[3], "-DKEEP"); -} + expect(eq(flags[0], "-I" + expected("/abs/hdr"))); + expect(eq(flags[1], "-isystem")); + expect(eq(flags[2], expected("/abs/hdr"))); + expect(eq(flags[3], "-DKEEP")); +}}; } // namespace // #634, item 11 of the triage record: a search-path entry that begins with a // token the loader expands is relative to a loaded object, not to the package // that wrote it. Both ldflag normalisers ask this one predicate. -TEST(BuildFlagsLoaderTokens, EveryLoaderTokenIsLeftAsWritten) { +UTest every_loader_token_is_left_as_written{"BuildFlagsLoaderTokens/EveryLoaderTokenIsLeftAsWritten", [] { for (std::string_view entry : {"$ORIGIN", "${ORIGIN}", "$ORIGIN/../lib", "$LIB", "@executable_path", "@executable_path/../Frameworks", "@loader_path", "@loader_path/", "@rpath", "@rpath/sub"}) { - EXPECT_TRUE(mcpp::build::is_loader_relative_search_path(entry)) << entry; + expect(mcpp::build::is_loader_relative_search_path(entry)) << entry; } -} +}}; -TEST(BuildFlagsLoaderTokens, OrdinaryPathsAreNotTokens) { +UTest ordinary_paths_are_not_tokens{"BuildFlagsLoaderTokens/OrdinaryPathsAreNotTokens", [] { for (std::string_view entry : {"", "lib", "./lib", "/opt/lib", "rpath", "@executable_pathology", "@rpathx", "x/@rpath"}) { - EXPECT_FALSE(mcpp::build::is_loader_relative_search_path(entry)) << entry; + expect(!mcpp::build::is_loader_relative_search_path(entry)) << entry; } -} +}}; // #647 E3: which link line a (host, target) pair takes. The branch used to be // chosen by `if constexpr` on the host, so an Android row built on a macOS host @@ -181,35 +183,35 @@ using mcpp::build::link_shape; using Fmt = mcpp::build::dist::Format; } // namespace -TEST(LinkShape, LinuxHostTakesTheGenericLineForEveryTarget) { +UTest linux_host_takes_the_generic_line_for_every_target{"LinkShape/LinuxHostTakesTheGenericLineForEveryTarget", [] { for (auto f : {Fmt::Elf, Fmt::MachO, Fmt::Pe, Fmt::Wasm}) { - EXPECT_EQ(link_shape(LinkHost::Linux, f, false, true), LinkShape::Generic); - EXPECT_EQ(link_shape(LinkHost::Linux, f, false, false), LinkShape::Generic); + expect(eq(link_shape(LinkHost::Linux, f, false, true), LinkShape::Generic)); + expect(eq(link_shape(LinkHost::Linux, f, false, false), LinkShape::Generic)); } -} +}}; -TEST(LinkShape, MacOSHostKeepsTheAppleLineForMachOOnly) { +UTest mac_os_host_keeps_the_apple_line_for_mach_o_only{"LinkShape/MacOSHostKeepsTheAppleLineForMachOOnly", [] { // macOS for macOS, and the iOS rows: the Apple SDK line, as before. - EXPECT_EQ(link_shape(LinkHost::MacOS, Fmt::MachO, false, false), LinkShape::AppleSdk); - EXPECT_EQ(link_shape(LinkHost::MacOS, Fmt::MachO, false, true), LinkShape::AppleSdk); + expect(eq(link_shape(LinkHost::MacOS, Fmt::MachO, false, false), LinkShape::AppleSdk)); + expect(eq(link_shape(LinkHost::MacOS, Fmt::MachO, false, true), LinkShape::AppleSdk)); // x86_64-linux-android and wasm32-emscripten on a Mac: the line that // carries `--target`. - EXPECT_EQ(link_shape(LinkHost::MacOS, Fmt::Elf, false, true), LinkShape::Generic); - EXPECT_EQ(link_shape(LinkHost::MacOS, Fmt::Wasm, false, true), LinkShape::Generic); - EXPECT_EQ(link_shape(LinkHost::MacOS, Fmt::Elf, false, false), LinkShape::Generic); - EXPECT_EQ(link_shape(LinkHost::MacOS, Fmt::Pe, false, true), LinkShape::Generic); -} - -TEST(LinkShape, WindowsHostSeparatesPeFromTargetsNamedByFlag) { - EXPECT_EQ(link_shape(LinkHost::Windows, Fmt::Pe, true, false), LinkShape::MsvcLinkExe); - EXPECT_EQ(link_shape(LinkHost::Windows, Fmt::Pe, false, true), LinkShape::PeLld); - EXPECT_EQ(link_shape(LinkHost::Windows, Fmt::Pe, false, false), LinkShape::PeLld); + expect(eq(link_shape(LinkHost::MacOS, Fmt::Elf, false, true), LinkShape::Generic)); + expect(eq(link_shape(LinkHost::MacOS, Fmt::Wasm, false, true), LinkShape::Generic)); + expect(eq(link_shape(LinkHost::MacOS, Fmt::Elf, false, false), LinkShape::Generic)); + expect(eq(link_shape(LinkHost::MacOS, Fmt::Pe, false, true), LinkShape::Generic)); +}}; + +UTest windows_host_separates_pe_from_targets_named_by_flag{"LinkShape/WindowsHostSeparatesPeFromTargetsNamedByFlag", [] { + expect(eq(link_shape(LinkHost::Windows, Fmt::Pe, true, false), LinkShape::MsvcLinkExe)); + expect(eq(link_shape(LinkHost::Windows, Fmt::Pe, false, true), LinkShape::PeLld)); + expect(eq(link_shape(LinkHost::Windows, Fmt::Pe, false, false), LinkShape::PeLld)); // An SDK or retargetable clang aimed at an ELF row: named by `--target`. - EXPECT_EQ(link_shape(LinkHost::Windows, Fmt::Elf, false, true), LinkShape::Generic); + expect(eq(link_shape(LinkHost::Windows, Fmt::Elf, false, true), LinkShape::Generic)); // The canadian GCC cross to x86_64-linux-musl names its target by prefix // and keeps the line its CI job verifies. - EXPECT_EQ(link_shape(LinkHost::Windows, Fmt::Elf, false, false), LinkShape::PeLld); -} + expect(eq(link_shape(LinkHost::Windows, Fmt::Elf, false, false), LinkShape::PeLld)); +}}; // ── c_abi_absent_facility_advice — the manifest reads back at the link ────── // @@ -218,26 +220,25 @@ TEST(LinkShape, WindowsHostSeparatesPeFromTargetsNamedByFlag) { // deliberate limit of the environment. The manifest already distinguishes // those (design 2026-09-20 §5.7.5); these pin that it is read back. -TEST(CAbiAbsentAdvice, ALinkAbsenceNamedInTheManifestIsExplained) { +UTest a_link_absence_named_in_the_manifest_is_explained{"CAbiAbsentAdvice/ALinkAbsenceNamedInTheManifestIsExplained", [] { std::vector absent{ {"fork", mcpp::targetside::CAbiAbsentForm::Link, "openkal has no process image duplication"}, }; auto a = mcpp::build::c_abi_absent_facility_advice( "ld.lld: error: undefined symbol: fork\n", "musl", absent); - ASSERT_FALSE(a.empty()); - EXPECT_NE(a.find("fork"), std::string::npos); - EXPECT_NE(a.find("no process image duplication"), std::string::npos); + expect(fatal(!a.empty())); + expect(ne(a.find("fork"), std::string::npos)); + expect(ne(a.find("no process image duplication"), std::string::npos)); // THE WHOLE CLAUSE, NOT `find("musl")`. The name was interpolated from // two conditionals -- an opening paren and the name -- and the closing // one was never emitted, so every reader saw `(musl declares that it // does not supply ...`. `find("musl")` is true of that sentence too, // which is why it took a real link to notice. Assert the rendering. - EXPECT_NE(a.find("the C library in this graph (musl) declares"), - std::string::npos) << a; -} + expect(ne(a.find("the C library in this graph (musl) declares"), std::string::npos)) << a; +}}; -TEST(CAbiAbsentAdvice, AnUnnamedCLibraryLeavesNoEmptyParentheses) { +UTest an_unnamed_c_library_leaves_no_empty_parentheses{"CAbiAbsentAdvice/AnUnnamedCLibraryLeavesNoEmptyParentheses", [] { // The other side of the same substitution: with no name there must be no // parenthetical at all, rather than `graph () declares`. std::vector absent{ @@ -245,22 +246,21 @@ TEST(CAbiAbsentAdvice, AnUnnamedCLibraryLeavesNoEmptyParentheses) { }; auto a = mcpp::build::c_abi_absent_facility_advice( "ld.lld: error: undefined symbol: fork\n", "", absent); - ASSERT_FALSE(a.empty()); - EXPECT_NE(a.find("the C library in this graph declares"), - std::string::npos) << a; - EXPECT_EQ(a.find("()"), std::string::npos) << a; -} + expect(fatal(!a.empty())); + expect(ne(a.find("the C library in this graph declares"), std::string::npos)) << a; + expect(eq(a.find("()"), std::string::npos)) << a; +}}; -TEST(CAbiAbsentAdvice, TheGnuSpellingIsMatchedToo) { +UTest the_gnu_spelling_is_matched_too{"CAbiAbsentAdvice/TheGnuSpellingIsMatchedToo", [] { std::vector absent{ {"fork", mcpp::targetside::CAbiAbsentForm::Link, ""}, }; auto a = mcpp::build::c_abi_absent_facility_advice( "main.o: undefined reference to `fork'\n", "musl", absent); - EXPECT_FALSE(a.empty()); -} + expect(!a.empty()); +}}; -TEST(CAbiAbsentAdvice, AnAbsenceThatReachesTheProgramAtRunTimeIsNotMatched) { +UTest an_absence_that_reaches_the_program_at_run_time_is_not_matched{"CAbiAbsentAdvice/AnAbsenceThatReachesTheProgramAtRunTimeIsNotMatched", [] { // `enosys` and `accepted-no-effect` reach a program by construction while // it runs; matching them against a link diagnostic would report a // coincidence of spelling as an explanation. @@ -268,27 +268,27 @@ TEST(CAbiAbsentAdvice, AnAbsenceThatReachesTheProgramAtRunTimeIsNotMatched) { {"mprotect", mcpp::targetside::CAbiAbsentForm::Enosys, "no protection op"}, {"tcsetattr", mcpp::targetside::CAbiAbsentForm::AcceptedNoEffect, ""}, }; - EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice( + expect(mcpp::build::c_abi_absent_facility_advice( "ld.lld: error: undefined symbol: mprotect\n", "musl", absent).empty()); - EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice( + expect(mcpp::build::c_abi_absent_facility_advice( "undefined reference to `tcsetattr'\n", "musl", absent).empty()); -} +}}; -TEST(CAbiAbsentAdvice, ALinkFailureNamingSomethingElseGetsNoNote) { +UTest a_link_failure_naming_something_else_gets_no_note{"CAbiAbsentAdvice/ALinkFailureNamingSomethingElseGetsNoNote", [] { std::vector absent{ {"fork", mcpp::targetside::CAbiAbsentForm::Link, ""}, }; - EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice( + expect(mcpp::build::c_abi_absent_facility_advice( "ld.lld: error: undefined symbol: my_own_function\n", "musl", absent) .empty()); -} +}}; -TEST(CAbiAbsentAdvice, ALibraryThatEnumeratedNothingProducesNoNote) { - EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice( +UTest a_library_that_enumerated_nothing_produces_no_note{"CAbiAbsentAdvice/ALibraryThatEnumeratedNothingProducesNoNote", [] { + expect(mcpp::build::c_abi_absent_facility_advice( "ld.lld: error: undefined symbol: fork\n", "musl", {}).empty()); -} +}}; -TEST(CAbiAbsentAdvice, ALongerSymbolWithTheSamePrefixIsNotExplained) { +UTest a_longer_symbol_with_the_same_prefix_is_not_explained{"CAbiAbsentAdvice/ALongerSymbolWithTheSamePrefixIsNotExplained", [] { // `undefined symbol: open` is a prefix of `undefined symbol: opendir`. // A substring search would answer a link failure with a row that has // nothing to do with it, and an explanation that is confidently wrong is @@ -296,13 +296,13 @@ TEST(CAbiAbsentAdvice, ALongerSymbolWithTheSamePrefixIsNotExplained) { std::vector absent{ {"open", mcpp::targetside::CAbiAbsentForm::Link, "not supplied"}, }; - EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice( + expect(mcpp::build::c_abi_absent_facility_advice( "ld.lld: error: undefined symbol: opendir\n", "musl", absent).empty()); - EXPECT_FALSE(mcpp::build::c_abi_absent_facility_advice( - "ld.lld: error: undefined symbol: open\n", "musl", absent).empty()); -} + expect(!(mcpp::build::c_abi_absent_facility_advice( + "ld.lld: error: undefined symbol: open\n", "musl", absent).empty())); +}}; -TEST(CAbiAbsentAdvice, TheListSurvivesAWriteAndReadBesideBuildNinja) { +UTest the_list_survives_a_write_and_read_beside_build_ninja{"CAbiAbsentAdvice/TheListSurvivesAWriteAndReadBesideBuildNinja", [] { // The two paths that report a failed build --- the one with a plan and the // fast path, which has none by construction --- must give the same advice, // or it appears depending on whether build.ninja happened to be up to @@ -314,27 +314,27 @@ TEST(CAbiAbsentAdvice, TheListSurvivesAWriteAndReadBesideBuildNinja) { }; mcpp::build::write_c_abi_absent_sidecar(dir.path, "musl", absent); auto [name, back] = mcpp::build::read_c_abi_absent_sidecar(dir.path); - EXPECT_EQ(name, "musl"); - ASSERT_EQ(back.size(), 2u); - EXPECT_EQ(back[0].name, "fork"); - EXPECT_EQ(back[0].form, mcpp::targetside::CAbiAbsentForm::Link); - EXPECT_EQ(back[0].note, "no image duplication"); - EXPECT_EQ(back[1].form, mcpp::targetside::CAbiAbsentForm::Enosys); - EXPECT_TRUE(back[1].note.empty()); + expect(eq(name, "musl")); + expect(fatal(eq(back.size(), 2u))); + expect(eq(back[0].name, "fork")); + expect(eq(back[0].form, mcpp::targetside::CAbiAbsentForm::Link)); + expect(eq(back[0].note, "no image duplication")); + expect(eq(back[1].form, mcpp::targetside::CAbiAbsentForm::Enosys)); + expect(back[1].note.empty()); // And the advice built from the read-back list is the advice the plan // path would have given. - EXPECT_FALSE(mcpp::build::c_abi_absent_facility_advice( - "ld.lld: error: undefined symbol: fork\n", name, back).empty()); -} + expect(!(mcpp::build::c_abi_absent_facility_advice( + "ld.lld: error: undefined symbol: fork\n", name, back).empty())); +}}; -TEST(CAbiAbsentAdvice, ADirectoryNoBuildWroteToYieldsNothing) { +UTest a_directory_no_build_wrote_to_yields_nothing{"CAbiAbsentAdvice/ADirectoryNoBuildWroteToYieldsNothing", [] { Tmp dir; auto [name, back] = mcpp::build::read_c_abi_absent_sidecar(dir.path); - EXPECT_TRUE(name.empty()); - EXPECT_TRUE(back.empty()); -} + expect(name.empty()); + expect(back.empty()); +}}; -TEST(CAbiAbsentAdvice, AGraphThatDeclaresNoAbsenceLeavesNoSidecar) { +UTest a_graph_that_declares_no_absence_leaves_no_sidecar{"CAbiAbsentAdvice/AGraphThatDeclaresNoAbsenceLeavesNoSidecar", [] { // A build whose C library states nothing must not leave a file behind for // the next build to read: the fast path would then explain a failure with // a list the current graph never declared. @@ -343,5 +343,5 @@ TEST(CAbiAbsentAdvice, AGraphThatDeclaresNoAbsenceLeavesNoSidecar) { {{"fork", mcpp::targetside::CAbiAbsentForm::Link, ""}}); mcpp::build::write_c_abi_absent_sidecar(dir.path, "", {}); auto [name, back] = mcpp::build::read_c_abi_absent_sidecar(dir.path); - EXPECT_TRUE(back.empty()); -} + expect(back.empty()); +}}; diff --git a/tests/unit/test_build_legs.cpp b/tests/unit/test_build_legs.cpp index c1a452d44..7a7d915e2 100644 --- a/tests/unit/test_build_legs.cpp +++ b/tests/unit/test_build_legs.cpp @@ -1,30 +1,32 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.prepare; import mcpp.toolchain.triple; +using namespace mcpplibs::mtest; + namespace legs = mcpp::build::legs; -TEST(BuildLegs, NativeArchitectureFirstTheRestInOrder) { +UTest native_architecture_first_the_rest_in_order{"BuildLegs/NativeArchitectureFirstTheRestInOrder", [] { const auto host = mcpp::toolchain::triple::host_triple().arch; const std::string other = host == "x86_64" ? "aarch64" : "x86_64"; std::vector in{other + "-openkal", host + "-openkal", other + "-linux-musl", host + "-linux-musl"}; auto out = legs::native_first(in); - EXPECT_EQ(out, (std::vector{host + "-openkal", host + "-linux-musl", - other + "-openkal", other + "-linux-musl"})); -} + expect(eq(out, (std::vector{host + "-openkal", host + "-linux-musl", + other + "-openkal", other + "-linux-musl"}))); +}}; -TEST(BuildLegs, FirstFailureIsTheFirstNonZero) { - EXPECT_EQ(legs::first_failure(std::vector{0, 0}), 0); - EXPECT_EQ(legs::first_failure(std::vector{0, 2, 1}), 2); - EXPECT_EQ(legs::first_failure(std::vector{}), 0); -} +UTest first_failure_is_the_first_non_zero{"BuildLegs/FirstFailureIsTheFirstNonZero", [] { + expect(eq(legs::first_failure(std::vector{0, 0}), 0)); + expect(eq(legs::first_failure(std::vector{0, 2, 1}), 2)); + expect(eq(legs::first_failure(std::vector{}), 0)); +}}; // One directory is one ninja's: work in it runs one item after another, and // distinct directories run at the same time. -TEST(BuildLegs, OneDirectoryIsWorkedOneItemAtATime) { +UTest one_directory_is_worked_one_item_at_a_time{"BuildLegs/OneDirectoryIsWorkedOneItemAtATime", [] { std::vector dirs{"a", "b", "a", "b", "a"}; std::mutex m; std::map inside, most; @@ -43,8 +45,8 @@ TEST(BuildLegs, OneDirectoryIsWorkedOneItemAtATime) { ++total; return static_cast(i); }); - EXPECT_EQ(total.load(), 5); - EXPECT_EQ(most["a"], 1); - EXPECT_EQ(most["b"], 1); - for (std::size_t i = 0; i < results.size(); ++i) EXPECT_EQ(results[i], static_cast(i)); -} + expect(eq(total.load(), 5)); + expect(eq(most["a"], 1)); + expect(eq(most["b"], 1)); + for (std::size_t i = 0; i < results.size(); ++i) expect(eq(results[i], static_cast(i))); +}}; diff --git a/tests/unit/test_build_profile.cpp b/tests/unit/test_build_profile.cpp index e2e7fae3c..56959b894 100644 --- a/tests/unit/test_build_profile.cpp +++ b/tests/unit/test_build_profile.cpp @@ -11,15 +11,17 @@ // `mcpp build --release` followed by a bare `mcpp build` reported success in // 0.00s and left the release artifacts in place. -#include #include import std; +import mcpplibs.mtest; import mcpp.invocation; import mcpp.build.prepare; import mcpp.manifest; import mcpplibs.os.platform; +using namespace mcpplibs::mtest; + namespace { mcpp::manifest::Manifest base() { @@ -45,49 +47,48 @@ class ScopedDeploymentTargetEnv { // ── resolve_profile_name: the ONE rule, shared with the fast paths ─────────── -TEST(BuildProfile, OverrideBeatsManifestDefault) { +UTest override_beats_manifest_default{"BuildProfile/OverrideBeatsManifestDefault", [] { auto m = base(); m.buildConfig.defaultProfile = "release"; - EXPECT_EQ(mcpp::build::resolve_profile_name(m, "dev"), "dev"); -} + expect(eq(mcpp::build::resolve_profile_name(m, "dev"), "dev")); +}}; -TEST(BuildProfile, ManifestDefaultBeatsGlobalDefault) { +UTest manifest_default_beats_global_default{"BuildProfile/ManifestDefaultBeatsGlobalDefault", [] { auto m = base(); m.buildConfig.defaultProfile = "release"; - EXPECT_EQ(mcpp::build::resolve_profile_name(m, ""), "release"); -} + expect(eq(mcpp::build::resolve_profile_name(m, ""), "release")); +}}; -TEST(BuildProfile, GlobalDefaultIsDev) { - EXPECT_EQ(mcpp::build::resolve_profile_name(base(), ""), "dev"); -} +UTest global_default_is_dev{"BuildProfile/GlobalDefaultIsDev", [] { + expect(eq(mcpp::build::resolve_profile_name(base(), ""), "dev")); +}}; -TEST(BuildProfile, CustomProfileNamePassesThrough) { - EXPECT_EQ(mcpp::build::resolve_profile_name(base(), "contracts"), "contracts"); -} +UTest custom_profile_name_passes_through{"BuildProfile/CustomProfileNamePassesThrough", [] { + expect(eq(mcpp::build::resolve_profile_name(base(), "contracts"), "contracts")); +}}; // ── the fingerprint invariant ──────────────────────────────────────────────── -TEST(BuildProfile, OptLevelIsInTheCanonicalFlags) { +UTest opt_level_is_in_the_canonical_flags{"BuildProfile/OptLevelIsInTheCanonicalFlags", [] { auto a = base(); auto b = base(); b.buildConfig.optLevel = "0"; - EXPECT_NE(mcpp::build::canonical_configuration_flags(a), - mcpp::build::canonical_configuration_flags(b)); -} + expect(ne(mcpp::build::canonical_configuration_flags(a), mcpp::build::canonical_configuration_flags(b))); +}}; -TEST(BuildProfile, DebugLtoStripAreInTheCanonicalFlags) { +UTest debug_lto_strip_are_in_the_canonical_flags{"BuildProfile/DebugLtoStripAreInTheCanonicalFlags", [] { auto ref = mcpp::build::canonical_configuration_flags(base()); { auto m = base(); m.buildConfig.debug = true; - EXPECT_NE(mcpp::build::canonical_configuration_flags(m), ref); } + expect(ne(mcpp::build::canonical_configuration_flags(m), ref)); } { auto m = base(); m.buildConfig.lto = true; - EXPECT_NE(mcpp::build::canonical_configuration_flags(m), ref); } + expect(ne(mcpp::build::canonical_configuration_flags(m), ref)); } { auto m = base(); m.buildConfig.strip = true; - EXPECT_NE(mcpp::build::canonical_configuration_flags(m), ref); } -} + expect(ne(mcpp::build::canonical_configuration_flags(m), ref)); } +}}; // The exact shape the three built-in profiles resolve to (prepare_build's // profile block). Asserting on the flag string rather than on a fingerprint hex // keeps the failure legible when someone adds a knob and forgets to serialize it. -TEST(BuildProfile, BuiltInProfilesProduceDistinctCanonicalFlags) { +UTest built_in_profiles_produce_distinct_canonical_flags{"BuildProfile/BuiltInProfilesProduceDistinctCanonicalFlags", [] { auto dev = base(); dev.buildConfig.optLevel = "0"; dev.buildConfig.debug = true; @@ -103,35 +104,35 @@ TEST(BuildProfile, BuiltInProfilesProduceDistinctCanonicalFlags) { auto frelease = mcpp::build::canonical_configuration_flags(release); auto fdist = mcpp::build::canonical_configuration_flags(dist); - EXPECT_NE(fdev, frelease); - EXPECT_NE(frelease, fdist); - EXPECT_NE(fdev, fdist); -} + expect(ne(fdev, frelease)); + expect(ne(frelease, fdist)); + expect(ne(fdev, fdist)); +}}; -TEST(BuildProfile, CanonicalFlagsStillCoverTheNonProfileKnobs) { +UTest canonical_flags_still_cover_the_non_profile_knobs{"BuildProfile/CanonicalFlagsStillCoverTheNonProfileKnobs", [] { auto ref = mcpp::build::canonical_configuration_flags(base()); { auto m = base(); m.package.standard = "c++26"; - EXPECT_NE(mcpp::build::canonical_configuration_flags(m), ref); } + expect(ne(mcpp::build::canonical_configuration_flags(m), ref)); } { auto m = base(); m.buildConfig.dialectCxxflags = {"-freflection"}; - EXPECT_NE(mcpp::build::canonical_configuration_flags(m), ref); } + expect(ne(mcpp::build::canonical_configuration_flags(m), ref)); } { auto m = base(); m.buildConfig.dependencyLinkage = "shared"; - EXPECT_NE(mcpp::build::canonical_configuration_flags(m), ref); } -} + expect(ne(mcpp::build::canonical_configuration_flags(m), ref)); } +}}; // Workspace design 2026-09-29 §3: a package's own flags are attributes of its // node, not inputs of the configuration, so editing them keeps the build // directory and ninja rebuilds the edges whose commands changed. -TEST(BuildProfile, PackageFlagsAreNotInTheConfiguration) { +UTest package_flags_are_not_in_the_configuration{"BuildProfile/PackageFlagsAreNotInTheConfiguration", [] { auto ref = mcpp::build::canonical_configuration_flags(base()); { auto m = base(); m.buildConfig.cxxflags = {"-DFOO"}; - EXPECT_EQ(mcpp::build::canonical_configuration_flags(m), ref); } + expect(eq(mcpp::build::canonical_configuration_flags(m), ref)); } { auto m = base(); m.buildConfig.cflags = {"-DBAR"}; - EXPECT_EQ(mcpp::build::canonical_configuration_flags(m), ref); } + expect(eq(mcpp::build::canonical_configuration_flags(m), ref)); } { auto m = base(); m.buildConfig.ldflags = {"-lm"}; - EXPECT_EQ(mcpp::build::canonical_configuration_flags(m), ref); } + expect(eq(mcpp::build::canonical_configuration_flags(m), ref)); } { auto m = base(); m.buildConfig.cStandard = "c17"; - EXPECT_EQ(mcpp::build::canonical_configuration_flags(m), ref); } -} + expect(eq(mcpp::build::canonical_configuration_flags(m), ref)); } +}}; // ── macos_deployment_target: TARGET-keyed, not HOST-keyed (mcpp#685) ──────── // @@ -147,7 +148,7 @@ TEST(BuildProfile, PackageFlagsAreNotInTheConfiguration) { // `TargetIsMacosFoldsDeploymentTargetIntoTheFingerprint` on any non-Apple CI // runner. -TEST(BuildProfile, NonMacosTargetIgnoresDeploymentTargetInTheFingerprint) { +UTest non_macos_target_ignores_deployment_target_in_the_fingerprint{"BuildProfile/NonMacosTargetIgnoresDeploymentTargetInTheFingerprint", [] { ScopedDeploymentTargetEnv noEnv(nullptr); auto ref = base(); auto changed = base(); changed.buildConfig.macosDeploymentTarget = "11.0"; @@ -155,18 +156,15 @@ TEST(BuildProfile, NonMacosTargetIgnoresDeploymentTargetInTheFingerprint) { // one building for something other than macOS) must not see the // fingerprint move just because the manifest names a macOS floor it will // never apply. - EXPECT_EQ(mcpp::build::canonical_configuration_flags(ref), - mcpp::build::canonical_configuration_flags(changed)); - EXPECT_EQ(mcpp::build::canonical_configuration_flags(ref, /*targetIsMacos=*/false), - mcpp::build::canonical_configuration_flags(changed, /*targetIsMacos=*/false)); -} + expect(eq(mcpp::build::canonical_configuration_flags(ref), mcpp::build::canonical_configuration_flags(changed))); + expect(eq(mcpp::build::canonical_configuration_flags(ref, /*targetIsMacos=*/false), mcpp::build::canonical_configuration_flags(changed, /*targetIsMacos=*/false))); +}}; -TEST(BuildProfile, TargetIsMacosFoldsDeploymentTargetIntoTheFingerprint) { +UTest target_is_macos_folds_deployment_target_into_the_fingerprint{"BuildProfile/TargetIsMacosFoldsDeploymentTargetIntoTheFingerprint", [] { ScopedDeploymentTargetEnv noEnv(nullptr); auto ref = base(); auto changed = base(); changed.buildConfig.macosDeploymentTarget = "11.0"; - EXPECT_NE(mcpp::build::canonical_configuration_flags(ref, /*targetIsMacos=*/true), - mcpp::build::canonical_configuration_flags(changed, /*targetIsMacos=*/true)); + expect(ne(mcpp::build::canonical_configuration_flags(ref, /*targetIsMacos=*/true), mcpp::build::canonical_configuration_flags(changed, /*targetIsMacos=*/true))); // AND THE ENV OVERRIDE, folded the same way `deployment_target` resolves // it: with MACOSX_DEPLOYMENT_TARGET set, the manifest value stops @@ -175,31 +173,30 @@ TEST(BuildProfile, TargetIsMacosFoldsDeploymentTargetIntoTheFingerprint) { ScopedDeploymentTargetEnv env("12.3"); auto manifestA = base(); manifestA.buildConfig.macosDeploymentTarget = "11.0"; auto manifestB = base(); manifestB.buildConfig.macosDeploymentTarget = "13.0"; - EXPECT_EQ(mcpp::build::canonical_configuration_flags(manifestA, /*targetIsMacos=*/true), - mcpp::build::canonical_configuration_flags(manifestB, /*targetIsMacos=*/true)); -} + expect(eq(mcpp::build::canonical_configuration_flags(manifestA, /*targetIsMacos=*/true), mcpp::build::canonical_configuration_flags(manifestB, /*targetIsMacos=*/true))); +}}; // ── cache-mode parsing ────────────────────────────────────────────────────── -TEST(BuildProfile, CacheModeParsesTheThreeModes) { +UTest cache_mode_parses_the_three_modes{"BuildProfile/CacheModeParsesTheThreeModes", [] { using mcpp::build::CacheMode; - EXPECT_EQ(mcpp::build::parse_cache_mode("global"), CacheMode::Global); - EXPECT_EQ(mcpp::build::parse_cache_mode("local"), CacheMode::Local); - EXPECT_EQ(mcpp::build::parse_cache_mode("off"), CacheMode::Off); + expect(eq(mcpp::build::parse_cache_mode("global"), CacheMode::Global)); + expect(eq(mcpp::build::parse_cache_mode("local"), CacheMode::Local)); + expect(eq(mcpp::build::parse_cache_mode("off"), CacheMode::Off)); // `none` accepted as a synonym for off; anything else is refused rather // than silently meaning "global", which would be the worst default for a // typo to land on. - EXPECT_EQ(mcpp::build::parse_cache_mode("none"), CacheMode::Off); - EXPECT_FALSE(mcpp::build::parse_cache_mode("on").has_value()); - EXPECT_FALSE(mcpp::build::parse_cache_mode("").has_value()); - EXPECT_FALSE(mcpp::build::parse_cache_mode("GLOBAL").has_value()); -} + expect(eq(mcpp::build::parse_cache_mode("none"), CacheMode::Off)); + expect(!(mcpp::build::parse_cache_mode("on").has_value())); + expect(!(mcpp::build::parse_cache_mode("").has_value())); + expect(!(mcpp::build::parse_cache_mode("GLOBAL").has_value())); +}}; -TEST(BuildProfile, CacheModeNameRoundTrips) { +UTest cache_mode_name_round_trips{"BuildProfile/CacheModeNameRoundTrips", [] { using mcpp::build::CacheMode; for (auto m : {CacheMode::Global, CacheMode::Local, CacheMode::Off}) - EXPECT_EQ(mcpp::build::parse_cache_mode(mcpp::build::cache_mode_name(m)), m); -} + expect(eq(mcpp::build::parse_cache_mode(mcpp::build::cache_mode_name(m)), m)); +}}; // ── cache-mode resolution ─────────────────────────────────────────────────── // Same shape as resolve_profile_name and for the same reason: the fast paths @@ -208,48 +205,44 @@ TEST(BuildProfile, CacheModeNameRoundTrips) { // edges reading the cache) got replayed for a request that asked for `local` — // and ruling the cache out is `local`'s entire purpose. -TEST(BuildProfile, CacheModeOverrideBeatsManifest) { +UTest cache_mode_override_beats_manifest{"BuildProfile/CacheModeOverrideBeatsManifest", [] { auto m = base(); m.buildConfig.cacheMode = "local"; - EXPECT_EQ(mcpp::build::resolve_cache_mode(m, "global"), - mcpp::build::CacheMode::Global); -} + expect(eq(mcpp::build::resolve_cache_mode(m, "global"), mcpp::build::CacheMode::Global)); +}}; -TEST(BuildProfile, CacheModeManifestBeatsDefault) { +UTest cache_mode_manifest_beats_default{"BuildProfile/CacheModeManifestBeatsDefault", [] { auto m = base(); m.buildConfig.cacheMode = "off"; - EXPECT_EQ(mcpp::build::resolve_cache_mode(m, ""), mcpp::build::CacheMode::Off); -} + expect(eq(mcpp::build::resolve_cache_mode(m, ""), mcpp::build::CacheMode::Off)); +}}; -TEST(BuildProfile, CacheModeDefaultsToGlobal) { - EXPECT_EQ(mcpp::build::resolve_cache_mode(base(), ""), - mcpp::build::CacheMode::Global); -} +UTest cache_mode_defaults_to_global{"BuildProfile/CacheModeDefaultsToGlobal", [] { + expect(eq(mcpp::build::resolve_cache_mode(base(), ""), mcpp::build::CacheMode::Global)); +}}; // An unparseable value must not silently mean "global" at THIS level either: it // falls through to the next source, and prepare_build reports it separately. -TEST(BuildProfile, UnknownManifestCacheModeFallsThroughToDefault) { +UTest unknown_manifest_cache_mode_falls_through_to_default{"BuildProfile/UnknownManifestCacheModeFallsThroughToDefault", [] { auto m = base(); m.buildConfig.cacheMode = "bogus"; - EXPECT_EQ(mcpp::build::resolve_cache_mode(m, ""), - mcpp::build::CacheMode::Global); + expect(eq(mcpp::build::resolve_cache_mode(m, ""), mcpp::build::CacheMode::Global)); // ...and an unparseable override does not shadow a valid manifest value. m.buildConfig.cacheMode = "local"; - EXPECT_EQ(mcpp::build::resolve_cache_mode(m, "bogus"), - mcpp::build::CacheMode::Local); -} + expect(eq(mcpp::build::resolve_cache_mode(m, "bogus"), mcpp::build::CacheMode::Local)); +}}; // ── #649 E9: the override the command line states, one rule for every verb ─── // // `build` preferred `--profile` over the shorthands and `run` preferred the // shorthands, so one command line built two profiles. Every verb now reads this. -TEST(BuildProfile, ProfileOptionBeatsTheShorthands) { - EXPECT_EQ(mcpp::build::profile_override_from_flags("dev", true, false), "dev"); - EXPECT_EQ(mcpp::build::profile_override_from_flags("release", false, true), "release"); -} - -TEST(BuildProfile, ShorthandsNameTheirProfile) { - EXPECT_EQ(mcpp::build::profile_override_from_flags("", true, false), "release"); - EXPECT_EQ(mcpp::build::profile_override_from_flags("", false, true), "dev"); - EXPECT_EQ(mcpp::build::profile_override_from_flags("", false, false), ""); -} +UTest profile_option_beats_the_shorthands{"BuildProfile/ProfileOptionBeatsTheShorthands", [] { + expect(eq(mcpp::build::profile_override_from_flags("dev", true, false), "dev")); + expect(eq(mcpp::build::profile_override_from_flags("release", false, true), "release")); +}}; + +UTest shorthands_name_their_profile{"BuildProfile/ShorthandsNameTheirProfile", [] { + expect(eq(mcpp::build::profile_override_from_flags("", true, false), "release")); + expect(eq(mcpp::build::profile_override_from_flags("", false, true), "dev")); + expect(eq(mcpp::build::profile_override_from_flags("", false, false), "")); +}}; diff --git a/tests/unit/test_build_progress.cpp b/tests/unit/test_build_progress.cpp index 80cf11676..6d0e3a373 100644 --- a/tests/unit/test_build_progress.cpp +++ b/tests/unit/test_build_progress.cpp @@ -1,10 +1,13 @@ -#include import std; +import mcpplibs.mtest; +import mcpplibs.os.stdio; import mcpp.ui; import mcpp.build.progress; import mcpp.build.action_progress; +using namespace mcpplibs::mtest; + // The build's report (.agents/docs/2026-09-29-build-progress-display-design.md, // revised by .agents/docs/2026-09-30-build-output-refinement-design.md): its // readers, the step record, the measures of text the renderer uses, the bytes @@ -44,37 +47,37 @@ std::size_t count(std::string_view hay, std::string_view needle) { // ─── The readers ───────────────────────────────────────────────────────── -TEST(ProgressStatus, ReadsTheCountsAndTheEndTime) { +UTest reads_the_counts_and_the_end_time{"ProgressStatus/ReadsTheCountsAndTheEndTime", [] { auto s = parse_status("\x1b[0m@@mcpp 3 12 0.027@@ OBJ obj/a.o"); - ASSERT_TRUE(s.has_value()); - EXPECT_EQ(s->finished, 3u); - EXPECT_EQ(s->total, 12u); - EXPECT_EQ(s->endMs, 27); - EXPECT_EQ(s->text, "OBJ obj/a.o"); + expect(fatal(s.has_value())); + expect(eq(s->finished, 3u)); + expect(eq(s->total, 12u)); + expect(eq(s->endMs, 27)); + expect(eq(s->text, "OBJ obj/a.o")); auto long_ = parse_status("\x1b[0m@@mcpp 1203 1203 2407.968@@ LINK bin/gui.exe"); - ASSERT_TRUE(long_.has_value()); - EXPECT_EQ(long_->endMs, 2407968); + expect(fatal(long_.has_value())); + expect(eq(long_->endMs, 2407968)); // A nested ninja's status line, which the outer ninja relays with its // escape sequence stripped, is output, not a status line. // `%u`: the steps not yet started (revision 3, §5.8); absent in a line of // the three-field format. - EXPECT_FALSE(s->unstarted.has_value()); + expect(!(s->unstarted.has_value())); auto u = parse_status("\x1b[0m@@mcpp 700 707 41.2 0@@ LINK bin/xlings"); - ASSERT_TRUE(u.has_value()); - EXPECT_EQ(u->finished, 700u); - EXPECT_EQ(u->total, 707u); - ASSERT_TRUE(u->unstarted.has_value()); - EXPECT_EQ(*u->unstarted, 0u); - EXPECT_EQ(u->text, "LINK bin/xlings"); - EXPECT_FALSE(parse_status("@@mcpp 3 12 0.027@@ OBJ obj/a.o").has_value()); - EXPECT_FALSE(parse_status("[3/12] OBJ obj/a.o").has_value()); - EXPECT_FALSE(parse_status("src/a.cpp:1:2: error: @@mcpp").has_value()); - EXPECT_FALSE(parse_status("").has_value()); -} - -TEST(ProgressLog, GroupsTheEntriesOfOneStep) { + expect(fatal(u.has_value())); + expect(eq(u->finished, 700u)); + expect(eq(u->total, 707u)); + expect(fatal(u->unstarted.has_value())); + expect(eq(*u->unstarted, 0u)); + expect(eq(u->text, "LINK bin/xlings")); + expect(!(parse_status("@@mcpp 3 12 0.027@@ OBJ obj/a.o").has_value())); + expect(!(parse_status("[3/12] OBJ obj/a.o").has_value())); + expect(!(parse_status("src/a.cpp:1:2: error: @@mcpp").has_value())); + expect(!(parse_status("").has_value())); +}}; + +UTest groups_the_entries_of_one_step{"ProgressLog/GroupsTheEntriesOfOneStep", [] { // Measured on ninja 1.12.1: a module unit's object and BMI share start, // end and hash; std.o and std.compat.o ended in the same millisecond with // different hashes, and are two steps. @@ -89,45 +92,45 @@ TEST(ProgressLog, GroupsTheEntriesOfOneStep) { int version = 0; std::vector offsets; auto steps = parse_log(text, &consumed, &version, &offsets); - EXPECT_EQ(version, 6); - ASSERT_EQ(steps.size(), 3u); - EXPECT_EQ(steps[0].outputs, std::vector{"obj/std.o"}); - EXPECT_EQ(steps[1].outputs, std::vector{"obj/std.compat.o"}); - EXPECT_EQ(steps[2].outputs, (std::vector{"obj/lib2.m.o", "pcm.cache/pm_lib2.pcm"})); - EXPECT_EQ(steps[2].start, 8); - EXPECT_EQ(steps[2].end, 21); - EXPECT_EQ(consumed, text.rfind('\n') + 1); - ASSERT_EQ(offsets.size(), 3u); - EXPECT_EQ(text.substr(offsets[2], 3), "8\t2"); -} - -TEST(ProgressLog, ThisRunIsTheSuffixWhoseEndsTheStatusLinesStated) { + expect(eq(version, 6)); + expect(fatal(eq(steps.size(), 3u))); + expect(eq(steps[0].outputs, std::vector{"obj/std.o"})); + expect(eq(steps[1].outputs, std::vector{"obj/std.compat.o"})); + expect(eq(steps[2].outputs, (std::vector{"obj/lib2.m.o", "pcm.cache/pm_lib2.pcm"}))); + expect(eq(steps[2].start, 8)); + expect(eq(steps[2].end, 21)); + expect(eq(consumed, text.rfind('\n') + 1)); + expect(fatal(eq(offsets.size(), 3u))); + expect(eq(text.substr(offsets[2], 3), "8\t2")); +}}; + +UTest this_run_is_the_suffix_whose_ends_the_status_lines_stated{"ProgressLog/ThisRunIsTheSuffixWhoseEndsTheStatusLinesStated", [] { // A recompacted log: the old entries first, in no particular order, then // this run's, whose ends the status lines gave. std::vector steps{{0, 900, {"old/a.o"}}, {5, 40, {"old/b.o"}}, {0, 12, {"new/x.o"}}, {3, 40, {"new/y.o"}}}; - EXPECT_EQ(run_boundary(steps, {12, 40}), 2u); + expect(eq(run_boundary(steps, {12, 40}), 2u)); // A status line whose entry is not written yet does not move the boundary. - EXPECT_EQ(run_boundary(steps, {12, 40, 55}), 2u); - EXPECT_EQ(run_boundary(steps, {}), 4u); -} + expect(eq(run_boundary(steps, {12, 40, 55}), 2u)); + expect(eq(run_boundary(steps, {}), 4u)); +}}; -TEST(ProgressStarts, ReadsCompleteLinesOnly) { +UTest reads_complete_lines_only{"ProgressStarts/ReadsCompleteLinesOnly", [] { std::size_t consumed = 0; auto s = parse_starts("gen/vcpkg.stamp\t1700000000000\n" "./gen/b.stamp\t1700000000500\n" "gen/c.st", &consumed); - ASSERT_EQ(s.size(), 2u); - EXPECT_EQ(s[0].stamp, "gen/vcpkg.stamp"); - EXPECT_EQ(s[1].stamp, "gen/b.stamp"); - EXPECT_EQ(s[1].unixMs, 1700000000500); - EXPECT_EQ(consumed, std::string_view("gen/vcpkg.stamp\t1700000000000\n" - "./gen/b.stamp\t1700000000500\n").size()); -} + expect(fatal(eq(s.size(), 2u))); + expect(eq(s[0].stamp, "gen/vcpkg.stamp")); + expect(eq(s[1].stamp, "gen/b.stamp")); + expect(eq(s[1].unixMs, 1700000000500)); + expect(eq(consumed, std::string_view("gen/vcpkg.stamp\t1700000000000\n" + "./gen/b.stamp\t1700000000500\n").size())); +}}; // ─── The step record ───────────────────────────────────────────────────── -TEST(ProgressRecord, EveryStatementIsRecordedWithItsOwner) { +UTest every_statement_is_recorded_with_its_owner{"ProgressRecord/EveryStatementIsRecordedWithItsOwner", [] { Attribution a; a.owner("app"); a.statement("build obj/main.o | pcm.cache/app.pcm : cxx_obj src/main.cpp\n flags = -O2\n"); @@ -142,66 +145,66 @@ TEST(ProgressRecord, EveryStatementIsRecordedWithItsOwner) { a.statement("rule cxx_obj\n command = c++ $in\n"); const auto& steps = a.steps(); - ASSERT_EQ(steps.size(), 5u); - EXPECT_EQ(steps[0].outputs, (std::vector{"obj/main.o", "pcm.cache/app.pcm"})); - EXPECT_EQ(steps[0].rule, "cxx_obj"); - EXPECT_EQ(steps[1].outputs, std::vector{"bin/app.bin"}); - EXPECT_EQ(steps[2].outputs, std::vector{"obj/with space.o"}); - EXPECT_EQ(steps[3].outputs, std::vector{"C:/abs/gen.stamp"}); - EXPECT_EQ(steps[4].owner, ""); + expect(fatal(eq(steps.size(), 5u))); + expect(eq(steps[0].outputs, (std::vector{"obj/main.o", "pcm.cache/app.pcm"}))); + expect(eq(steps[0].rule, "cxx_obj")); + expect(eq(steps[1].outputs, std::vector{"bin/app.bin"})); + expect(eq(steps[2].outputs, std::vector{"obj/with space.o"})); + expect(eq(steps[3].outputs, std::vector{"C:/abs/gen.stamp"})); + expect(eq(steps[4].owner, "")); // The first explicit input, unescaped; an implicit input is not one. - EXPECT_EQ(steps[0].input, "src/main.cpp"); - EXPECT_EQ(steps[2].input, "src/with space.cpp"); - EXPECT_EQ(steps[3].input, "a.in"); + expect(eq(steps[0].input, "src/main.cpp")); + expect(eq(steps[2].input, "src/with space.cpp")); + expect(eq(steps[3].input, "a.in")); auto r = a.record({{"app", true, "app v0.1.0 (.)", 0, 0}}); - EXPECT_EQ(r.steps, 5u); - ASSERT_EQ(r.packages.size(), 2u); - EXPECT_EQ(r.packages[0].steps, 2u); - EXPECT_EQ(r.packages[0].subject, "app v0.1.0 (.)"); + expect(eq(r.steps, 5u)); + expect(fatal(eq(r.packages.size(), 2u))); + expect(eq(r.packages[0].steps, 2u)); + expect(eq(r.packages[0].subject, "app v0.1.0 (.)")); // An owner the plan did not declare is shown by its name, as a dependency. - EXPECT_EQ(r.packages[1].name, "mcpplibs.lib"); - EXPECT_FALSE(r.packages[1].requested); - EXPECT_EQ(r.packages[1].steps, 2u); - EXPECT_EQ(r.owner.at("pcm.cache/app.pcm"), 0u); - EXPECT_EQ(r.owner.at("obj/with space.o"), 1u); - EXPECT_FALSE(r.owner.contains("obj/mcpp_ios_init.o")); - EXPECT_EQ(r.actions.at("C:/abs/gen.stamp"), "vcpkg install"); + expect(eq(r.packages[1].name, "mcpplibs.lib")); + expect(!(r.packages[1].requested)); + expect(eq(r.packages[1].steps, 2u)); + expect(eq(r.owner.at("pcm.cache/app.pcm"), 0u)); + expect(eq(r.owner.at("obj/with space.o"), 1u)); + expect(!r.owner.contains("obj/mcpp_ios_init.o")); + expect(eq(r.actions.at("C:/abs/gen.stamp"), "vcpkg install")); // The outputs of one step share its identity. - EXPECT_EQ(r.step.at("obj/main.o"), r.step.at("pcm.cache/app.pcm")); - EXPECT_NE(r.step.at("obj/main.o"), r.step.at("bin/app.bin")); + expect(eq(r.step.at("obj/main.o"), r.step.at("pcm.cache/app.pcm"))); + expect(ne(r.step.at("obj/main.o"), r.step.at("bin/app.bin"))); r.packages[0].detail = "v0.1.0 (.)"; r.packages[0].source = "project"; auto back = parse_record(format_record(r)); - EXPECT_EQ(back.steps, r.steps); - ASSERT_EQ(back.packages.size(), 2u); - EXPECT_EQ(back.packages[0].subject, "app v0.1.0 (.)"); - EXPECT_EQ(back.packages[0].detail, "v0.1.0 (.)"); - EXPECT_EQ(back.packages[0].source, "project"); - EXPECT_TRUE(back.packages[0].requested); - EXPECT_EQ(back.owner, r.owner); - EXPECT_EQ(back.step, r.step); - EXPECT_EQ(back.actions, r.actions); -} - -TEST(ProgressRecord, AVersionOneRecordIsRead) { + expect(eq(back.steps, r.steps)); + expect(fatal(eq(back.packages.size(), 2u))); + expect(eq(back.packages[0].subject, "app v0.1.0 (.)")); + expect(eq(back.packages[0].detail, "v0.1.0 (.)")); + expect(eq(back.packages[0].source, "project")); + expect(bool(back.packages[0].requested)); + expect(eq(back.owner, r.owner)); + expect(eq(back.step, r.step)); + expect(eq(back.actions, r.actions)); +}}; + +UTest a_version_one_record_is_read{"ProgressRecord/AVersionOneRecordIsRead", [] { // 2026.9.29.5 wrote version 1: six fields to a package line, the subject // carrying the origin. The fast path of a newer mcpp reads it as it is. auto r = parse_record("# mcpp steps v1\t3\nP\txlings\t1\t0\t2\txlings (.)\n" "O\t0\t0\tobj/a.o\n"); - EXPECT_EQ(r.steps, 3u); - ASSERT_EQ(r.packages.size(), 1u); - EXPECT_EQ(r.packages[0].subject, "xlings (.)"); - EXPECT_TRUE(r.packages[0].detail.empty()); - EXPECT_TRUE(r.packages[0].source.empty()); - EXPECT_TRUE(r.descriptor.empty()); - EXPECT_TRUE(r.sources.empty()); - EXPECT_EQ(r.owner.at("obj/a.o"), 0u); -} - -TEST(ProgressRecord, ACompileIsNamedByItsSourceAndTheHeaderCarriesTheDescriptor) { + expect(eq(r.steps, 3u)); + expect(fatal(eq(r.packages.size(), 1u))); + expect(eq(r.packages[0].subject, "xlings (.)")); + expect(r.packages[0].detail.empty()); + expect(r.packages[0].source.empty()); + expect(r.descriptor.empty()); + expect(r.sources.empty()); + expect(eq(r.owner.at("obj/a.o"), 0u)); +}}; + +UTest a_compile_is_named_by_its_source_and_the_header_carries_the_descriptor{"ProgressRecord/ACompileIsNamedByItsSourceAndTheHeaderCarriesTheDescriptor", [] { // An absolute path on this host, spelled as build.ninja spells it. const bool drive = !std::filesystem::path("/w").is_absolute(); const std::string root = drive ? "C:/w" : "/w", ninjaRoot = drive ? "C$:/w" : "/w"; @@ -217,122 +220,119 @@ TEST(ProgressRecord, ACompileIsNamedByItsSourceAndTheHeaderCarriesTheDescriptor) a.statement("build obj/util/none.stamp: stamp || obj/util/src/u.o\n"); // no explicit input auto r = a.record({{"app", true, "app", 0, 0}}, {root + "/app", root + "/app/deps/util"}); - EXPECT_EQ(r.sources.at(r.step.at("obj/app/src/main.o")), "src/main.cpp"); + expect(eq(r.sources.at(r.step.at("obj/app/src/main.o")), "src/main.cpp")); // The innermost root that holds the file. - EXPECT_EQ(r.sources.at(r.step.at("obj/util/src/u.o")), "src/u.cpp"); - EXPECT_FALSE(r.sources.contains(r.step.at("obj/app/gen.o"))); - EXPECT_FALSE(r.sources.contains(r.step.at("obj/app/x.o"))); - EXPECT_FALSE(r.sources.contains(r.step.at("bin/app"))); - EXPECT_FALSE(r.sources.contains(r.step.at("obj/util/none.stamp"))); + expect(eq(r.sources.at(r.step.at("obj/util/src/u.o")), "src/u.cpp")); + expect(!(r.sources.contains(r.step.at("obj/app/gen.o")))); + expect(!(r.sources.contains(r.step.at("obj/app/x.o")))); + expect(!(r.sources.contains(r.step.at("bin/app")))); + expect(!(r.sources.contains(r.step.at("obj/util/none.stamp")))); r.descriptor = "unoptimized + debuginfo"; auto back = parse_record(format_record(r)); - EXPECT_EQ(back.sources, r.sources); - EXPECT_EQ(back.descriptor, "unoptimized + debuginfo"); + expect(eq(back.sources, r.sources)); + expect(eq(back.descriptor, "unoptimized + debuginfo")); // The fast path reads the header line alone. Tmp t; write_record(t.path, r); - EXPECT_EQ(read_descriptor(t.path), "unoptimized + debuginfo"); - EXPECT_EQ(read_descriptor(t.path / "absent"), ""); -} + expect(eq(read_descriptor(t.path), "unoptimized + debuginfo")); + expect(eq(read_descriptor(t.path / "absent"), "")); +}}; -TEST(ProgressRecord, PathsAreComparedNormalised) { - EXPECT_EQ(normalise("./obj\\a.o"), "obj/a.o"); - EXPECT_EQ(normalise("obj/../obj/b.o"), "obj/b.o"); -} +UTest paths_are_compared_normalised{"ProgressRecord/PathsAreComparedNormalised", [] { + expect(eq(normalise("./obj\\a.o"), "obj/a.o")); + expect(eq(normalise("obj/../obj/b.o"), "obj/b.o")); +}}; // ─── Measures of text ──────────────────────────────────────────────────── -TEST(ProgressText, DisplayWidthCountsColumns) { - EXPECT_EQ(mcpp::ui::display_width("abc"), 3u); - EXPECT_EQ(mcpp::ui::display_width("\033[1m\033[92mabc\033[0m"), 3u); - EXPECT_EQ(mcpp::ui::display_width("中文"), 4u); - EXPECT_EQ(mcpp::ui::display_width("a · b → c …"), 11u); -} - -TEST(ProgressText, FitCutsByColumnsAndClosesColour) { - EXPECT_EQ(mcpp::ui::fit("abcdef", 6), "abcdef"); - EXPECT_EQ(mcpp::ui::fit("abcdef", 4), "abc…"); - EXPECT_EQ(mcpp::ui::fit("中文路径", 5), "中文…"); +UTest display_width_counts_columns{"ProgressText/DisplayWidthCountsColumns", [] { + expect(eq(mcpp::ui::display_width("abc"), 3u)); + expect(eq(mcpp::ui::display_width("\033[1m\033[92mabc\033[0m"), 3u)); + expect(eq(mcpp::ui::display_width("中文"), 4u)); + expect(eq(mcpp::ui::display_width("a · b → c …"), 11u)); +}}; + +UTest fit_cuts_by_columns_and_closes_colour{"ProgressText/FitCutsByColumnsAndClosesColour", [] { + expect(eq(mcpp::ui::fit("abcdef", 6), "abcdef")); + expect(eq(mcpp::ui::fit("abcdef", 4), "abc…")); + expect(eq(mcpp::ui::fit("中文路径", 5), "中文…")); auto coloured = mcpp::ui::fit("\033[32mabcdef\033[0m", 3); - EXPECT_EQ(mcpp::ui::display_width(coloured), 3u); - EXPECT_TRUE(coloured.ends_with("\033[0m")); -} + expect(eq(mcpp::ui::display_width(coloured), 3u)); + expect(coloured.ends_with("\033[0m")); +}}; -TEST(ProgressText, OneFormatForEveryDuration) { +UTest one_format_for_every_duration{"ProgressText/OneFormatForEveryDuration", [] { using ms = std::chrono::milliseconds; - EXPECT_EQ(mcpp::ui::format_duration(ms(840)), "0.84s"); - EXPECT_EQ(mcpp::ui::format_duration(ms(12'340)), "12.34s"); - EXPECT_EQ(mcpp::ui::format_duration(ms(192'000)), "3m12s"); - EXPECT_EQ(mcpp::ui::format_duration(ms(3'720'000)), "1h02m"); - EXPECT_EQ(mcpp::ui::format_clock(ms(872'000)), "14:32"); - EXPECT_EQ(mcpp::ui::format_clock(ms(3'730'000)), "1:02:10"); -} - -TEST(ProgressText, TheStateStartsAtTheBlocksColumn) { + expect(eq(mcpp::ui::format_duration(ms(840)), "0.84s")); + expect(eq(mcpp::ui::format_duration(ms(12'340)), "12.34s")); + expect(eq(mcpp::ui::format_duration(ms(192'000)), "3m12s")); + expect(eq(mcpp::ui::format_duration(ms(3'720'000)), "1h02m")); + expect(eq(mcpp::ui::format_clock(ms(872'000)), "14:32")); + expect(eq(mcpp::ui::format_clock(ms(3'730'000)), "1:02:10")); +}}; + +UTest the_state_starts_at_the_blocks_column{"ProgressText/TheStateStartsAtTheBlocksColumn", [] { mcpp::ui::disable_color(); - EXPECT_EQ(mcpp::ui::step_line("Compiling", "core (core)", 16, "done 1.20s"), - " Compiling core (core) done 1.20s"); + expect(eq(mcpp::ui::step_line("Compiling", "core (core)", 16, "done 1.20s"), " Compiling core (core) done 1.20s")); // A longer subject pushes its state two columns further. - EXPECT_EQ(mcpp::ui::step_line("Compiling", "a-long-subject-here", 16, "failed"), - " Compiling a-long-subject-here failed"); -} + expect(eq(mcpp::ui::step_line("Compiling", "a-long-subject-here", 16, "failed"), " Compiling a-long-subject-here failed")); +}}; // ─── The region ────────────────────────────────────────────────────────── -TEST(ProgressRegion, TheStatusLineIsTheLastRowWithNoBlankRow) { +UTest the_status_line_is_the_last_row_with_no_blank_row{"ProgressRegion/TheStatusLineIsTheLastRowWithNoBlankRow", [] { mcpp::ui::Frame f{{}, " Building 612/1203 · 14:32"}; auto rows = mcpp::ui::region_rows({"bar"}, f, 10); - ASSERT_EQ(rows.size(), 2u); - EXPECT_EQ(rows[0], "bar"); - EXPECT_EQ(rows[1], " Building 612/1203 · 14:32"); -} + expect(fatal(eq(rows.size(), 2u))); + expect(eq(rows[0], "bar")); + expect(eq(rows[1], " Building 612/1203 · 14:32")); +}}; -TEST(ProgressRegion, LinesBeyondTheRoomAreSummarised) { +UTest lines_beyond_the_room_are_summarised{"ProgressRegion/LinesBeyondTheRoomAreSummarised", [] { mcpp::ui::Frame f; for (int i = 0; i < 14; ++i) f.lines.push_back(std::format("line {}", i)); f.status = "Building"; auto rows = mcpp::ui::region_rows({}, f, 10); - ASSERT_EQ(rows.size(), 11u); // 9 lines, the summary, the status - EXPECT_NE(rows[9].find("… 5 more"), std::string::npos); -} + expect(fatal(eq(rows.size(), 11u))); // 9 lines, the summary, the status + expect(ne(rows[9].find("… 5 more"), std::string::npos)); +}}; -TEST(ProgressRegion, AFrameOverwritesTheOldRowsInsteadOfErasingThem) { +UTest a_frame_overwrites_the_old_rows_instead_of_erasing_them{"ProgressRegion/AFrameOverwritesTheOldRowsInsteadOfErasingThem", [] { // From the first row of a region of two rows: the line over the first, // the new status row over the second, the tail of each cleared, and only // then what is left below. No row is erased before it is written // (revision 3, §9.2). - EXPECT_EQ(mcpp::ui::frame_bytes(2, " Compiling a\n", {"s"}), - "\r\033[1A Compiling a\033[K\n\033[?7ls\033[K\033[?7h\033[J"); + expect(eq(mcpp::ui::frame_bytes(2, " Compiling a\n", {"s"}), "\r\033[1A Compiling a\033[K\n\033[?7ls\033[K\033[?7h\033[J")); // A region drawn for the first time starts where the cursor is. - EXPECT_EQ(mcpp::ui::frame_bytes(0, {}, {"s"}), "\033[?7ls\033[K\033[?7h"); + expect(eq(mcpp::ui::frame_bytes(0, {}, {"s"}), "\033[?7ls\033[K\033[?7h")); // Erasing leaves the cursor at the region's first row. - EXPECT_EQ(mcpp::ui::frame_bytes(1, {}, {}), "\r\033[J"); + expect(eq(mcpp::ui::frame_bytes(1, {}, {}), "\r\033[J")); auto bytes = mcpp::ui::redraw_bytes(3, {"a", "status"}); - EXPECT_TRUE(bytes.starts_with("\r\033[2A\033[?7la")) << bytes; - EXPECT_EQ(count(bytes, "\033[J"), 1u); - EXPECT_TRUE(bytes.ends_with("\033[J")); -} + expect(bytes.starts_with("\r\033[2A\033[?7la")) << bytes; + expect(eq(count(bytes, "\033[J"), 1u)); + expect(bytes.ends_with("\033[J")); +}}; -TEST(ProgressText, AmbiguousCharactersCountTwoWhereTheTerminalDrawsThemWide) { +UTest ambiguous_characters_count_two_where_the_terminal_draws_them_wide{"ProgressText/AmbiguousCharactersCountTwoWhereTheTerminalDrawsThemWide", [] { mcpp::ui::set_ambiguous_wide(true); - EXPECT_EQ(mcpp::ui::display_width("·"), 2u); - EXPECT_EQ(mcpp::ui::display_width("━─"), 4u); - EXPECT_EQ(mcpp::ui::display_width("⣿⣀"), 2u); // braille is narrow everywhere + expect(eq(mcpp::ui::display_width("·"), 2u)); + expect(eq(mcpp::ui::display_width("━─"), 4u)); + expect(eq(mcpp::ui::display_width("⣿⣀"), 2u)); // braille is narrow everywhere mcpp::ui::set_ambiguous_wide(false); - EXPECT_EQ(mcpp::ui::display_width("·"), 1u); -} + expect(eq(mcpp::ui::display_width("·"), 1u)); +}}; -TEST(ProgressText, ThePhaseIsAlignedWithTheVerbs) { +UTest the_phase_is_aligned_with_the_verbs{"ProgressText/ThePhaseIsAlignedWithTheVerbs", [] { mcpp::ui::disable_color(); - EXPECT_EQ(mcpp::ui::status_line("Building", "612/707 · 0:35"), " Building 612/707 · 0:35"); - EXPECT_EQ(mcpp::ui::status_line("Running", "· 0:04"), " Running · 0:04"); -} + expect(eq(mcpp::ui::status_line("Building", "612/707 · 0:35"), " Building 612/707 · 0:35")); + expect(eq(mcpp::ui::status_line("Running", "· 0:04"), " Running · 0:04")); +}}; // ─── When a package is named (revision 3, §5.3) ────────────────────────── -TEST(ProgressModel, AnActionStartLoadsTheFastPathRecordBeforeNinjaWritesStdout) { +UTest an_action_start_loads_the_fast_path_record_before_ninja_writes_stdout{"ProgressModel/AnActionStartLoadsTheFastPathRecordBeforeNinjaWritesStdout", [] { namespace telemetry = mcpp::build::action_progress; Tmp tmp; Record rec; @@ -346,13 +346,13 @@ TEST(ProgressModel, AnActionStartLoadsTheFastPathRecordBeforeNinjaWritesStdout) build.pass_begin(); const auto env = build.environment(); const auto root = std::ranges::find(env, telemetry::kRootEnv, &decltype(env)::value_type::first); - ASSERT_NE(root, env.end()); + expect(fatal(ne(root, env.end()))); std::ofstream(telemetry::channel(root->second, "gen/check.stamp")) << R"({"v":1,"seq":1,"phase":"internal-check","done":1,"total":2})"; const auto unixMs = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); append(tmp.path / kStartsFile, std::format("gen/check.stamp\t{}\n", unixMs)); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); open(false); const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); std::string row; @@ -362,14 +362,14 @@ TEST(ProgressModel, AnActionStartLoadsTheFastPathRecordBeforeNinjaWritesStdout) std::this_thread::sleep_for(std::chrono::milliseconds(10)); } while (std::chrono::steady_clock::now() < deadline); close(); - (void)testing::internal::GetCapturedStderr(); - EXPECT_TRUE(build.has_record()); - EXPECT_NE(row.find("check-action"), std::string::npos) << row; - EXPECT_NE(row.find("internal-check 1/2"), std::string::npos) << row; + (void)stderrCapture.text(); + expect(build.has_record()); + expect(ne(row.find("check-action"), std::string::npos)) << row; + expect(ne(row.find("internal-check 1/2"), std::string::npos)) << row; // No Build::status() or completed ninja-log entry was supplied. -} +}}; -TEST(ProgressModel, ANewSnapshotRedrawsBeforeTheClockHeartbeat) { +UTest a_new_snapshot_redraws_before_the_clock_heartbeat{"ProgressModel/ANewSnapshotRedrawsBeforeTheClockHeartbeat", [] { namespace telemetry = mcpp::build::action_progress; Tmp tmp; Record rec; @@ -381,13 +381,13 @@ TEST(ProgressModel, ANewSnapshotRedrawsBeforeTheClockHeartbeat) { build.pass_begin(); const auto env = build.environment(); const auto root = std::ranges::find(env, telemetry::kRootEnv, &decltype(env)::value_type::first); - ASSERT_NE(root, env.end()); + expect(fatal(ne(root, env.end()))); const auto unixMs = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); append(tmp.path / kStartsFile, std::format("gen/redraw.stamp\t{}\n", unixMs)); const bool wasLive = mcpp::ui::live_progress(); mcpp::ui::set_live_progress(true); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); open(false); // Let the initial half-second display guard pass. This line then draws // the frame immediately before publication, resetting the clock redraw. @@ -397,13 +397,13 @@ TEST(ProgressModel, ANewSnapshotRedrawsBeforeTheClockHeartbeat) { << R"({"v":1,"seq":1,"phase":"snapshot-redraw","done":1,"total":2})"; std::this_thread::sleep_for(std::chrono::milliseconds(500)); close(); - const auto out = testing::internal::GetCapturedStderr(); + const auto out = stderrCapture.text(); mcpp::ui::set_live_progress(wasLive); - EXPECT_NE(out.find("snapshot-redraw 1/2"), std::string::npos) << out; + expect(ne(out.find("snapshot-redraw 1/2"), std::string::npos)) << out; // No Ninja stdout, completion or other narration requested a redraw. -} +}}; -TEST(ProgressModel, APackageIsNamedOnceWhenItsFirstStepFinishes) { +UTest a_package_is_named_once_when_its_first_step_finishes{"ProgressModel/APackageIsNamedOnceWhenItsFirstStepFinishes", [] { mcpp::ui::disable_color(); Tmp tmp; Record rec; @@ -416,7 +416,7 @@ TEST(ProgressModel, APackageIsNamedOnceWhenItsFirstStepFinishes) { rec.steps = 4; Build b(tmp.path); b.set_record(rec); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); b.pass_begin(); const auto log = tmp.path / ".ninja_log"; append(log, "# ninja log v6\n1\t10\t0\tobj/dep.o\taa\n"); @@ -427,22 +427,22 @@ TEST(ProgressModel, APackageIsNamedOnceWhenItsFirstStepFinishes) { b.status({4, 4, 50, 0, {}}); b.pass_end(); b.finish(true); - auto out = testing::internal::GetCapturedStderr(); + auto out = stderrCapture.text(); // The dependency first, as its first step finished first; each once; the // standard library module is the toolchain's and is not named. const auto dep = out.find("Compiling compat.dep v1.0.0"); const auto app = out.find("Compiling app v0.1.0 (.)"); - ASSERT_NE(dep, std::string::npos) << out; - ASSERT_NE(app, std::string::npos) << out; - EXPECT_LT(dep, app) << out; - EXPECT_EQ(count(out, "app v0.1.0"), 1u) << out; - EXPECT_EQ(out.find("std"), std::string::npos) << out; + expect(fatal(ne(dep, std::string::npos))) << out; + expect(fatal(ne(app, std::string::npos))) << out; + expect(lt(dep, app)) << out; + expect(eq(count(out, "app v0.1.0"), 1u)) << out; + expect(eq(out.find("std"), std::string::npos)) << out; // No outcome, no fold: the line states that the package compiles. - EXPECT_EQ(out.find("done"), std::string::npos) << out; - EXPECT_EQ(out.find("dependenc"), std::string::npos) << out; -} + expect(eq(out.find("done"), std::string::npos)) << out; + expect(eq(out.find("dependenc"), std::string::npos)) << out; +}}; -TEST(ProgressModel, APackageTheCacheServesIsNamedCachedWithItsUnits) { +UTest a_package_the_cache_serves_is_named_cached_with_its_units{"ProgressModel/APackageTheCacheServesIsNamedCachedWithItsUnits", [] { mcpp::ui::disable_color(); Tmp tmp; Record rec; @@ -451,16 +451,16 @@ TEST(ProgressModel, APackageTheCacheServesIsNamedCachedWithItsUnits) { rec.step = {{"obj/a.o", 0}, {"obj/b.o", 1}}; Build b(tmp.path); b.set_record(rec); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); b.pass_begin(); append(tmp.path / ".ninja_log", "# ninja log v6\n1\t2\t0\tobj/a.o\taa\n1\t2\t0\tobj/b.o\tbb\n"); b.status({2, 2, 2, 0, {}}); b.pass_end(); b.finish(true); - auto out = testing::internal::GetCapturedStderr(); - EXPECT_NE(out.find("Cached compat.ftxui v6.1.9 (73 units)"), std::string::npos) << out; - EXPECT_EQ(count(out, "compat.ftxui"), 1u) << out; -} + auto out = stderrCapture.text(); + expect(ne(out.find("Cached compat.ftxui v6.1.9 (73 units)"), std::string::npos)) << out; + expect(eq(count(out, "compat.ftxui"), 1u)) << out; +}}; // ─── What the count counts (build wall-time plan, W2) ──────────────────── // @@ -469,7 +469,7 @@ TEST(ProgressModel, APackageTheCacheServesIsNamedCachedWithItsUnits) { // scans, and read 967/1195 when its first compile began. A placement pass is // read but not counted, a scan pass shows as `Scanning f/t`, and the main pass // alone is `Building f/t`. -TEST(ProgressModel, OnlyTheMainPassIsCountedAsBuilding) { +UTest only_the_main_pass_is_counted_as_building{"ProgressModel/OnlyTheMainPassIsCountedAsBuilding", [] { mcpp::ui::disable_color(); Tmp tmp; Record rec; @@ -477,7 +477,7 @@ TEST(ProgressModel, OnlyTheMainPassIsCountedAsBuilding) { rec.steps = 1; Build b(tmp.path); b.set_record(rec); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); b.pass_begin(mcpp::build::progress::PassKind::Placement); b.status({503, 503, 1, 0, {}}); const auto placing = mcpp::build::progress::status_row(); @@ -491,15 +491,15 @@ TEST(ProgressModel, OnlyTheMainPassIsCountedAsBuilding) { const auto building = mcpp::build::progress::status_row(); b.pass_end(); b.finish(true); - testing::internal::GetCapturedStderr(); - EXPECT_EQ(placing.find("503"), std::string::npos) << placing; - EXPECT_NE(scanning.find("Scanning"), std::string::npos) << scanning; - EXPECT_NE(scanning.find("200/460"), std::string::npos) << scanning; - EXPECT_NE(building.find("Building"), std::string::npos) << building; - EXPECT_NE(building.find(" 1/232"), std::string::npos) << building; -} - -TEST(ProgressModel, AFailureNamesItsPackageOnce) { + stderrCapture.text(); + expect(eq(placing.find("503"), std::string::npos)) << placing; + expect(ne(scanning.find("Scanning"), std::string::npos)) << scanning; + expect(ne(scanning.find("200/460"), std::string::npos)) << scanning; + expect(ne(building.find("Building"), std::string::npos)) << building; + expect(ne(building.find(" 1/232"), std::string::npos)) << building; +}}; + +UTest a_failure_names_its_package_once{"ProgressModel/AFailureNamesItsPackageOnce", [] { mcpp::ui::disable_color(); Tmp tmp; Record rec; @@ -509,7 +509,7 @@ TEST(ProgressModel, AFailureNamesItsPackageOnce) { rec.step = {{"obj/a.o", 0}, {"obj/l.o", 1}}; Build b(tmp.path); b.set_record(rec); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); b.pass_begin(); // A failed step is not in ninja's log: the package is named from the // FAILED line. @@ -517,14 +517,14 @@ TEST(ProgressModel, AFailureNamesItsPackageOnce) { const auto second = b.failed("obj/a.o "); b.pass_end(); b.finish(false); - auto out = testing::internal::GetCapturedStderr(); - ASSERT_TRUE(first.has_value()); - EXPECT_EQ(*first, "lib v0.1.0 (lib)"); - EXPECT_FALSE(second.has_value()); - EXPECT_EQ(count(out, "Compiling lib v0.1.0 (lib)"), 1u) << out; -} - -TEST(ProgressModel, AStepWhoseEntriesAreReadInTwoPiecesCountsOnce) { + auto out = stderrCapture.text(); + expect(fatal(first.has_value())); + expect(eq(*first, "lib v0.1.0 (lib)")); + expect(!second.has_value()); + expect(eq(count(out, "Compiling lib v0.1.0 (lib)"), 1u)) << out; +}}; + +UTest a_step_whose_entries_are_read_in_two_pieces_counts_once{"ProgressModel/AStepWhoseEntriesAreReadInTwoPiecesCountsOnce", [] { // ninja writes one log entry per output and flushes each: a read can land // between a module unit's object and its BMI. mcpp::ui::disable_color(); @@ -537,7 +537,7 @@ TEST(ProgressModel, AStepWhoseEntriesAreReadInTwoPiecesCountsOnce) { rec.step = {{"obj/lib.m.o", 0}, {"pcm.cache/lib.pcm", 0}, {"bin/lib.a", 1}}; Build b(tmp.path); b.set_record(rec); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); b.pass_begin(); const auto log = tmp.path / ".ninja_log"; append(log, "# ninja log v6\n1\t10\t0\tobj/lib.m.o\taa\n"); @@ -546,15 +546,15 @@ TEST(ProgressModel, AStepWhoseEntriesAreReadInTwoPiecesCountsOnce) { b.status({1, 2, 10, {}, {}}); b.pass_end(); b.finish(true); - auto out = testing::internal::GetCapturedStderr(); - EXPECT_EQ(count(out, "Compiling mlib"), 1u) << out; -} + auto out = stderrCapture.text(); + expect(eq(count(out, "Compiling mlib"), 1u)) << out; +}}; -TEST(ProgressModel, APackageTwoConfigurationsShareIsAnnouncedOncePerCommand) { +UTest a_package_two_configurations_share_is_announced_once_per_command{"ProgressModel/APackageTwoConfigurationsShareIsAnnouncedOncePerCommand", [] { // A workspace builds one plan per configuration; a dependency they share // is the same line in each, and the command writes it once (L6). mcpp::ui::disable_color(); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); for (int config = 0; config < 2; ++config) { Tmp tmp; Record rec; @@ -569,19 +569,19 @@ TEST(ProgressModel, APackageTwoConfigurationsShareIsAnnouncedOncePerCommand) { b.pass_end(); b.finish(true); } - auto out = testing::internal::GetCapturedStderr(); - EXPECT_EQ(count(out, "Compiling shared v0.2.0 (shared)"), 1u) << out; -} + auto out = stderrCapture.text(); + expect(eq(count(out, "Compiling shared v0.2.0 (shared)"), 1u)) << out; +}}; // `Finished` is written once per command. `mcpp run --format ` packs, and // the pack states its build with `Finished`; the run then drives the same build // again, and its second `Finished` repeated the line with the first's time // counted twice (pack drive and selection design 2026-10-01, A3). -TEST(ProgressModel, FinishedIsWrittenOncePerCommand) { +UTest finished_is_written_once_per_command{"ProgressModel/FinishedIsWrittenOncePerCommand", [] { mcpp::ui::disable_color(); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); mcpp::build::progress::finished("release", "optimized"); mcpp::build::progress::finished("release", "optimized"); - auto out = testing::internal::GetCapturedStderr(); - EXPECT_EQ(count(out, "Finished"), 1u) << out; -} + auto out = stderrCapture.text(); + expect(eq(count(out, "Finished"), 1u)) << out; +}}; diff --git a/tests/unit/test_build_resources.cpp b/tests/unit/test_build_resources.cpp index 6d8369907..4e8e30456 100644 --- a/tests/unit/test_build_resources.cpp +++ b/tests/unit/test_build_resources.cpp @@ -1,36 +1,32 @@ -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.build.resources; import mcpplibs.os.platform; import mcpp.toolchain.detect; +using namespace mcpplibs::mtest; + namespace res = mcpp::build::resources; namespace fs = std::filesystem; -TEST(BuildResources, CoffTargetFollowsTheTargetInsteadOfTheHost) { +UTest coff_target_follows_the_target_instead_of_the_host{"BuildResources/CoffTargetFollowsTheTargetInsteadOfTheHost", [] { const res::RcTool llvm{"/selected/bin/llvm-windres.exe", "gnu", true}; - EXPECT_EQ(res::coff_target_flag(llvm, "i686-windows-msvc"), - "--target=i686-pc-windows-msvc"); - EXPECT_EQ(res::coff_target_flag(llvm, "x86_64-windows-msvc"), - "--target=x86_64-pc-windows-msvc"); - EXPECT_EQ(res::coff_target_flag(llvm, "aarch64-windows-msvc"), - "--target=aarch64-pc-windows-msvc"); + expect(eq(res::coff_target_flag(llvm, "i686-windows-msvc"), "--target=i686-pc-windows-msvc")); + expect(eq(res::coff_target_flag(llvm, "x86_64-windows-msvc"), "--target=x86_64-pc-windows-msvc")); + expect(eq(res::coff_target_flag(llvm, "aarch64-windows-msvc"), "--target=aarch64-pc-windows-msvc")); const res::RcTool gnu{"/selected/bin/windres", "gnu"}; for (const auto arch : {"x86", "i386", "i486", "i586", "i686"}) { - EXPECT_EQ(res::coff_target_flag(gnu, std::string(arch) + "-windows-gnu"), - "--target=pe-i386") << arch; + expect(eq(res::coff_target_flag(gnu, std::string(arch) + "-windows-gnu"), "--target=pe-i386")) << arch; } - EXPECT_EQ(res::coff_target_flag(llvm, "i386-windows-msvc"), - "--target=i386-pc-windows-msvc"); + expect(eq(res::coff_target_flag(llvm, "i386-windows-msvc"), "--target=i386-pc-windows-msvc")); // MSVC's spelling reaches LLVM as the triple LLVM accepts (#776 review). - EXPECT_EQ(res::coff_target_flag(llvm, "x86-windows-msvc"), - "--target=i686-pc-windows-msvc"); - EXPECT_EQ(res::coff_target_flag(gnu, "x86_64-windows-gnu"), "--target=pe-x86-64"); - EXPECT_TRUE(res::coff_target_flag({"rc.exe", "msvc"}, "i686-windows-msvc").empty()); - EXPECT_TRUE(res::coff_target_flag({"llvm-rc.exe", "msvc"}, "x86_64-windows-msvc").empty()); -} + expect(eq(res::coff_target_flag(llvm, "x86-windows-msvc"), "--target=i686-pc-windows-msvc")); + expect(eq(res::coff_target_flag(gnu, "x86_64-windows-gnu"), "--target=pe-x86-64")); + expect(res::coff_target_flag({"rc.exe", "msvc"}, "i686-windows-msvc").empty()); + expect(res::coff_target_flag({"llvm-rc.exe", "msvc"}, "x86_64-windows-msvc").empty()); +}}; namespace { @@ -66,38 +62,38 @@ struct TempDir { // ─── Which windres: decided where the tool is found ─────────────────────── -TEST(BuildResources, LlvmWindresIsRecognisedThroughTheSymlinkThatNamesIt) { +UTest llvm_windres_is_recognised_through_the_symlink_that_names_it{"BuildResources/LlvmWindresIsRecognisedThroughTheSymlinkThatNamesIt", [] { if constexpr (mcpplibs::os::is_windows) - GTEST_SKIP() << "creating a symlink needs a privilege a Windows runner may lack"; + skip("creating a symlink needs a privilege a Windows runner may lack"); TempDir d; const auto real = d.write("llvm-rc", ""); std::error_code ec; fs::create_symlink(real, d.path / "llvm-windres", ec); - ASSERT_FALSE(ec) << ec.message(); + expect(fatal(!ec)) << ec.message(); fs::create_symlink(d.path / "llvm-windres", d.path / "i686-w64-mingw32-windres", ec); - ASSERT_FALSE(ec) << ec.message(); + expect(fatal(!ec)) << ec.message(); const auto gnu = d.write("windres", ""); - EXPECT_TRUE(res::is_llvm_windres(d.path / "llvm-windres")); - EXPECT_TRUE(res::is_llvm_windres(d.path / "i686-w64-mingw32-windres")); - EXPECT_FALSE(res::is_llvm_windres(gnu)); - EXPECT_FALSE(res::is_llvm_windres(d.path / "absent-windres")); + expect(res::is_llvm_windres(d.path / "llvm-windres")); + expect(res::is_llvm_windres(d.path / "i686-w64-mingw32-windres")); + expect(!res::is_llvm_windres(gnu)); + expect(!res::is_llvm_windres(d.path / "absent-windres")); // And `find_rc_tool` records it: the llvm-mingw spelling gets the triple. mcpp::toolchain::Toolchain tc; tc.binaryPath = d.path / "clang"; tc.targetTriple = "i686-windows-gnu"; auto tool = res::find_rc_tool(tc, "gnu"); - ASSERT_TRUE(tool.has_value()); - EXPECT_EQ(tool->name(), "i686-w64-mingw32-windres"); - EXPECT_TRUE(tool->llvm); - EXPECT_EQ(res::coff_target_flag(*tool, tc.targetTriple), "--target=i686-w64-windows-gnu"); -} + expect(fatal(tool.has_value())); + expect(eq(tool->name(), "i686-w64-mingw32-windres")); + expect(bool(tool->llvm)); + expect(eq(res::coff_target_flag(*tool, tc.targetTriple), "--target=i686-w64-windows-gnu")); +}}; // ─── The build program's manifest object: the command is part of it ─────── -TEST(BuildResources, TheManifestObjectIsRemadeWhenItsCommandChanges) { +UTest the_manifest_object_is_remade_when_its_command_changes{"BuildResources/TheManifestObjectIsRemadeWhenItsCommandChanges", [] { if constexpr (mcpplibs::os::is_windows) - GTEST_SKIP() << "the stand-in resource compiler is a POSIX shell script"; + skip("the stand-in resource compiler is a POSIX shell script"); TempDir d; // A windres that writes the words it was given into its output. const auto tool = d.write("windres", @@ -114,23 +110,23 @@ TEST(BuildResources, TheManifestObjectIsRemadeWhenItsCommandChanges) { }; auto first = res::compile_utf8_manifest(tc, "gnu", d.path / "out", "m"); - ASSERT_TRUE(first.has_value()) << first.error(); - EXPECT_NE(read(*first).find("--target=pe-x86-64"), std::string::npos); + if (auto&& failed = expect(fatal(first.has_value())); !failed) failed << first.error(); + expect(ne(read(*first).find("--target=pe-x86-64"), std::string::npos)); // The same command reuses the object: a marker written into it survives. { std::ofstream(*first, std::ios::binary) << "reused"; } auto again = res::compile_utf8_manifest(tc, "gnu", d.path / "out", "m"); - ASSERT_TRUE(again.has_value()) << again.error(); - EXPECT_EQ(read(*again), "reused"); + if (auto&& failed = expect(fatal(again.has_value())); !failed) failed << again.error(); + expect(eq(read(*again), "reused")); // Another target is another command, so the object is made again. Before // 2026.10.5.3 only the manifest and the script were compared, and this // returned the x86-64 object. tc.targetTriple = "i686-windows-gnu"; auto other = res::compile_utf8_manifest(tc, "gnu", d.path / "out", "m"); - ASSERT_TRUE(other.has_value()) << other.error(); - EXPECT_NE(read(*other).find("--target=pe-i386"), std::string::npos); -} + if (auto&& failed = expect(fatal(other.has_value())); !failed) failed << other.error(); + expect(ne(read(*other).find("--target=pe-i386"), std::string::npos)); +}}; // ─── Synthesis: the mcpp#365 headline ───────────────────────────────────── // @@ -140,110 +136,110 @@ TEST(BuildResources, TheManifestObjectIsRemadeWhenItsCommandChanges) { // while GetFileVersionInfo looks up ordinal 1. What mcpp generates must // therefore never spell the macro. -TEST(BuildResources, SynthesizedScriptNamesTheVersionResourceByOrdinal) { +UTest synthesized_script_names_the_version_resource_by_ordinal{"BuildResources/SynthesizedScriptNamesTheVersionResourceByOrdinal", [] { mcpp::manifest::Resources r; auto rc = res::synthesize_rc(sample_package(), r, "myapp.exe", {}); - ASSERT_TRUE(rc) << rc.error(); - EXPECT_NE(rc->find("1 VERSIONINFO"), std::string::npos); - EXPECT_EQ(rc->find("VS_VERSION_INFO"), std::string::npos) + if (auto&& failed = expect(fatal(bool(rc))); !failed) failed << rc.error(); + expect(ne(rc->find("1 VERSIONINFO"), std::string::npos)); + expect(eq(rc->find("VS_VERSION_INFO"), std::string::npos)) << "the macro is only defined when was included; naming it " "here is the bug this feature exists to avoid"; -} +}}; -TEST(BuildResources, FileVersionComesFromThePackageVersion) { +UTest file_version_comes_from_the_package_version{"BuildResources/FileVersionComesFromThePackageVersion", [] { mcpp::manifest::Resources r; auto rc = res::synthesize_rc(sample_package(), r, "myapp.exe", {}); - ASSERT_TRUE(rc); - EXPECT_NE(rc->find("FILEVERSION 0,2,0,0"), std::string::npos); - EXPECT_NE(rc->find("PRODUCTVERSION 0,2,0,0"), std::string::npos); + expect(fatal(bool(rc))); + expect(ne(rc->find("FILEVERSION 0,2,0,0"), std::string::npos)); + expect(ne(rc->find("PRODUCTVERSION 0,2,0,0"), std::string::npos)); // The STRING fields keep the version verbatim, so a form the numeric // fields cannot hold (a pre-release) is still visible in the properties // dialog. - EXPECT_NE(rc->find("\"FileVersion\", \"0.2.0\""), std::string::npos); -} + expect(ne(rc->find("\"FileVersion\", \"0.2.0\""), std::string::npos)); +}}; -TEST(BuildResources, FourSegmentDateVersionsFitTheNumericFields) { +UTest four_segment_date_versions_fit_the_numeric_fields{"BuildResources/FourSegmentDateVersionsFitTheNumericFields", [] { auto pkg = sample_package(); pkg.version = "2026.8.7.1"; mcpp::manifest::Resources r; auto rc = res::synthesize_rc(pkg, r, "mcpp.exe", {}); - ASSERT_TRUE(rc) << rc.error(); - EXPECT_NE(rc->find("FILEVERSION 2026,8,7,1"), std::string::npos); -} + if (auto&& failed = expect(fatal(bool(rc))); !failed) failed << rc.error(); + expect(ne(rc->find("FILEVERSION 2026,8,7,1"), std::string::npos)); +}}; -TEST(BuildResources, AVersionFieldThatCannotFitIsAnErrorNotAClamp) { +UTest a_version_field_that_cannot_fit_is_an_error_not_a_clamp{"BuildResources/AVersionFieldThatCannotFitIsAnErrorNotAClamp", [] { auto pkg = sample_package(); pkg.version = "70000.0.0"; // > 65535: FILEVERSION fields are 16-bit mcpp::manifest::Resources r; auto rc = res::synthesize_rc(pkg, r, "myapp.exe", {}); - ASSERT_FALSE(rc); - EXPECT_NE(rc.error().find("65535"), std::string::npos); + expect(fatal(!rc)); + expect(ne(rc.error().find("65535"), std::string::npos)); // Clamping would put a version in the binary that is not the version that // was built — the failure mode is a wrong answer, so it must not be silent. - EXPECT_NE(rc.error().find("70000"), std::string::npos); -} + expect(ne(rc.error().find("70000"), std::string::npos)); +}}; -TEST(BuildResources, MetadataDefaultsFromPackageAndIsOverridable) { +UTest metadata_defaults_from_package_and_is_overridable{"BuildResources/MetadataDefaultsFromPackageAndIsOverridable", [] { mcpp::manifest::Resources r; auto rc = res::synthesize_rc(sample_package(), r, "myapp.exe", {}); - ASSERT_TRUE(rc); - EXPECT_NE(rc->find("\"CompanyName\", \"Acme\""), std::string::npos); - EXPECT_NE(rc->find("\"ProductName\", \"myapp\""), std::string::npos); - EXPECT_NE(rc->find("\"FileDescription\", \"My application\""), std::string::npos); - EXPECT_NE(rc->find("\"OriginalFilename\", \"myapp.exe\""), std::string::npos); + expect(fatal(bool(rc))); + expect(ne(rc->find("\"CompanyName\", \"Acme\""), std::string::npos)); + expect(ne(rc->find("\"ProductName\", \"myapp\""), std::string::npos)); + expect(ne(rc->find("\"FileDescription\", \"My application\""), std::string::npos)); + expect(ne(rc->find("\"OriginalFilename\", \"myapp.exe\""), std::string::npos)); r.info.company = "Other Co"; r.info.product = "Renamed"; auto rc2 = res::synthesize_rc(sample_package(), r, "myapp.exe", {}); - ASSERT_TRUE(rc2); - EXPECT_NE(rc2->find("\"CompanyName\", \"Other Co\""), std::string::npos); - EXPECT_NE(rc2->find("\"ProductName\", \"Renamed\""), std::string::npos); -} + expect(fatal(bool(rc2))); + expect(ne(rc2->find("\"CompanyName\", \"Other Co\""), std::string::npos)); + expect(ne(rc2->find("\"ProductName\", \"Renamed\""), std::string::npos)); +}}; -TEST(BuildResources, GeneratedTextIsAsciiSoItDoesNotDependOnTheCodepageFlag) { +UTest generated_text_is_ascii_so_it_does_not_depend_on_the_codepage_flag{"BuildResources/GeneratedTextIsAsciiSoItDoesNotDependOnTheCodepageFlag", [] { // A UTF-8 codepage is passed to the rc tool for the USER's metadata, but // text mcpp writes itself must not need it: an em dash in the default // copyright line failed llvm-rc outright ("Non-ASCII 8-bit codepoint"). mcpp::manifest::Resources r; auto rc = res::synthesize_rc(sample_package(), r, "myapp.exe", {}); - ASSERT_TRUE(rc); + expect(fatal(bool(rc))); auto generated = rc->substr(0, rc->find("VALUE \"CompanyName\"")); for (unsigned char c : generated) - EXPECT_LT(c, 0x80u) << "generated scaffolding must stay ASCII"; - EXPECT_NE(rc->find("\"LegalCopyright\", \"(C) Acme - MIT\""), std::string::npos); -} + expect(lt(c, 0x80u)) << "generated scaffolding must stay ASCII"; + expect(ne(rc->find("\"LegalCopyright\", \"(C) Acme - MIT\""), std::string::npos)); +}}; -TEST(BuildResources, IconIsEmittedAtOrdinalOne) { +UTest icon_is_emitted_at_ordinal_one{"BuildResources/IconIsEmittedAtOrdinalOne", [] { mcpp::manifest::Resources r; r.icon = "assets/app.ico"; auto rc = res::synthesize_rc(sample_package(), r, "myapp.exe", "/proj/assets/app.ico"); - ASSERT_TRUE(rc); + expect(fatal(bool(rc))); // Explorer shows the lowest-numbered icon group. - EXPECT_NE(rc->find("1 ICON \"/proj/assets/app.ico\""), std::string::npos); -} + expect(ne(rc->find("1 ICON \"/proj/assets/app.ico\""), std::string::npos)); +}}; // The 3-row rule: author-supplied scripts own the resource ID space, so mcpp // does not add a second VERSIONINFO behind their back — unless asked. -TEST(BuildResources, VersionInfoSynthesisFollowsTheThreeRowRule) { +UTest version_info_synthesis_follows_the_three_row_rule{"BuildResources/VersionInfoSynthesisFollowsTheThreeRowRule", [] { mcpp::manifest::Resources r; - EXPECT_TRUE(r.synthesize_version_info()); // nothing declared + expect(bool(r.synthesize_version_info())); // nothing declared r.files = {"res/app.rc"}; - EXPECT_FALSE(r.synthesize_version_info()); // author took over + expect(!r.synthesize_version_info()); // author took over r.versionInfo = true; - EXPECT_TRUE(r.synthesize_version_info()); // explicit opt-in wins + expect(bool(r.synthesize_version_info())); // explicit opt-in wins r.versionInfo = false; - EXPECT_FALSE(r.synthesize_version_info()); + expect(!r.synthesize_version_info()); r.files.clear(); - EXPECT_FALSE(r.synthesize_version_info()); // explicit opt-out wins -} + expect(!r.synthesize_version_info()); // explicit opt-out wins +}}; // ─── Scanning an author-written .rc ─────────────────────────────────────── -TEST(BuildResources, ScanCollectsQuotedIncludesAndDataFiles) { +UTest scan_collects_quoted_includes_and_data_files{"BuildResources/ScanCollectsQuotedIncludesAndDataFiles", [] { TempDir d; auto rc = d.write("app.rc", R"(#include "ids.h" #include @@ -260,25 +256,25 @@ END return std::any_of(s.inputs.begin(), s.inputs.end(), [&](const fs::path& p){ return p.filename() == leaf; }); }; - EXPECT_TRUE(has("ids.h")); - EXPECT_TRUE(has("app.ico")); - EXPECT_TRUE(has("blob.bin")); + expect(bool(has("ids.h"))); + expect(bool(has("app.ico"))); + expect(bool(has("blob.bin"))); // An application manifest is a file like the icon (#693): type 24 at the // TYPE position, spelt numerically, which no keyword names. - EXPECT_TRUE(has("app.manifest")); - EXPECT_TRUE(s.declaresManifest); + expect(bool(has("app.manifest"))); + expect(bool(s.declaresManifest)); // Angled includes belong to the toolchain: immutable for the life of a // build directory and already folded into the fingerprint. - EXPECT_FALSE(has("windows.h")); + expect(!has("windows.h")); // STRINGTABLE carries its data inline — nothing to track. - EXPECT_EQ(s.inputs.size(), 4u); -} + expect(eq(s.inputs.size(), 4u)); +}}; // A quoted include and a resource file are found where the resource compiler // finds them: beside the script, then in the include directories it is given. // A name found nowhere stays beside the script, where a file a build action // produces is ordered before the compile. -TEST(BuildResources, InputsAreResolvedThroughTheIncludeDirectories) { +UTest inputs_are_resolved_through_the_include_directories{"BuildResources/InputsAreResolvedThroughTheIncludeDirectories", [] { TempDir d; fs::create_directories(d.path / "include"); d.write("include/ids.h", "#define APP_ICON 101\n"); @@ -295,15 +291,15 @@ TEST(BuildResources, InputsAreResolvedThroughTheIncludeDirectories) { for (auto const& p : s.inputs) if (p.filename() == leaf) return p; return fs::path{}; }; - EXPECT_EQ(at("ids.h"), d.path / "include" / "ids.h"); - EXPECT_EQ(at("app.ico"), d.path / "include" / "app.ico"); - EXPECT_EQ(at("local.ico"), d.path / "local.ico"); - EXPECT_EQ(at("generated.ico"), d.path / "generated.ico"); -} + expect(eq(at("ids.h"), d.path / "include" / "ids.h")); + expect(eq(at("app.ico"), d.path / "include" / "app.ico")); + expect(eq(at("local.ico"), d.path / "local.ico")); + expect(eq(at("generated.ico"), d.path / "generated.ico")); +}}; // The numbers of a VERSIONINFO block contain 24 as well; only the TYPE // position of a statement declares a manifest. -TEST(BuildResources, ATwentyFourOutsideTheTypePositionIsNotAManifest) { +UTest a_twenty_four_outside_the_type_position_is_not_a_manifest{"BuildResources/ATwentyFourOutsideTheTypePositionIsNotAManifest", [] { TempDir d; auto rc = d.write("app.rc", R"(1 VERSIONINFO FILEVERSION 24,1,0,0 @@ -312,53 +308,52 @@ BEGIN END )"); auto s = res::scan_rc(rc); - EXPECT_FALSE(s.declaresManifest); - EXPECT_TRUE(s.inputs.empty()); -} + expect(!s.declaresManifest); + expect(s.inputs.empty()); +}}; // The manifest `windows_code_page = "utf-8"` embeds sits at ordinal 1 of type // 24, and nothing else is added to a script synthesised for it alone. -TEST(BuildResources, AManifestOnlyScriptCarriesTheManifestAndNothingElse) { +UTest a_manifest_only_script_carries_the_manifest_and_nothing_else{"BuildResources/AManifestOnlyScriptCarriesTheManifestAndNothingElse", [] { mcpp::manifest::Resources r; r.versionInfo = false; auto rc = res::synthesize_rc(sample_package(), r, "tool.exe", {}, fs::path("/b/res/tool.mcpp.manifest")); - ASSERT_TRUE(rc) << rc.error(); - EXPECT_NE(rc->find("1 24 \"/b/res/tool.mcpp.manifest\""), std::string::npos) << *rc; - EXPECT_EQ(rc->find("VERSIONINFO"), std::string::npos) << *rc; - EXPECT_EQ(rc->find("ICON"), std::string::npos) << *rc; - EXPECT_NE(res::utf8_code_page_manifest().find( - "UTF-8"), - std::string::npos); -} + if (auto&& failed = expect(fatal(bool(rc))); !failed) failed << rc.error(); + if (auto&& failed = expect(ne(rc->find("1 24 \"/b/res/tool.mcpp.manifest\""), std::string::npos)); !failed) failed << *rc; + if (auto&& failed = expect(eq(rc->find("VERSIONINFO"), std::string::npos)); !failed) failed << *rc; + if (auto&& failed = expect(eq(rc->find("ICON"), std::string::npos)); !failed) failed << *rc; + expect(ne(res::utf8_code_page_manifest().find( + "UTF-8"), std::string::npos)); +}}; // An application manifest states what was asked of it and nothing else; a // host tool's states both (design 2026-10-16 §11). -TEST(BuildResources, AnApplicationManifestStatesWhatWasAskedOfIt) { +UTest an_application_manifest_states_what_was_asked_of_it{"BuildResources/AnApplicationManifestStatesWhatWasAskedOfIt", [] { const std::string codePage = "UTF-8"; const std::string longPaths = "true"; const auto both = res::application_manifest(true, true); - EXPECT_NE(both.find(codePage), std::string::npos); - EXPECT_NE(both.find(longPaths), std::string::npos); + expect(ne(both.find(codePage), std::string::npos)); + expect(ne(both.find(longPaths), std::string::npos)); const auto onlyLong = res::application_manifest(false, true); - EXPECT_EQ(onlyLong.find("activeCodePage"), std::string::npos); - EXPECT_NE(onlyLong.find(longPaths), std::string::npos); - EXPECT_EQ(res::application_manifest(true, false).find("longPathAware"), std::string::npos); - EXPECT_EQ(res::utf8_code_page_manifest(), both); -} + expect(eq(onlyLong.find("activeCodePage"), std::string::npos)); + expect(ne(onlyLong.find(longPaths), std::string::npos)); + expect(eq(res::application_manifest(true, false).find("longPathAware"), std::string::npos)); + expect(eq(res::utf8_code_page_manifest(), both)); +}}; -TEST(BuildResources, ScanNamesWhatItCouldNotResolve) { +UTest scan_names_what_it_could_not_resolve{"BuildResources/ScanNamesWhatItCouldNotResolve", [] { TempDir d; auto rc = d.write("app.rc", "1 ICON APP_ICON\n"); auto s = res::scan_rc(rc); // A macro hides the file name. Reporting the gap is the whole point: a // silently untracked input leaves a stale resource in a shipped binary. - ASSERT_EQ(s.gaps.size(), 1u); - EXPECT_NE(s.gaps[0].find("APP_ICON"), std::string::npos); - EXPECT_TRUE(s.inputs.empty()); -} + expect(fatal(eq(s.gaps.size(), 1u))); + expect(ne(s.gaps[0].find("APP_ICON"), std::string::npos)); + expect(s.inputs.empty()); +}}; -TEST(BuildResources, ScanFlagsAVersionResourceWindowsWillNotFind) { +UTest scan_flags_a_version_resource_windows_will_not_find{"BuildResources/ScanFlagsAVersionResourceWindowsWillNotFind", [] { TempDir d; auto rc = d.write("bad.rc", R"(VS_VERSION_INFO VERSIONINFO FILEVERSION 0,2,0,0 @@ -366,13 +361,13 @@ BEGIN END )"); auto s = res::scan_rc(rc); - EXPECT_TRUE(s.versionInfoNamedByString); - EXPECT_EQ(s.versionInfoName, "VS_VERSION_INFO"); -} + expect(bool(s.versionInfoNamedByString)); + expect(eq(s.versionInfoName, "VS_VERSION_INFO")); +}}; // ─── Splitting a Windows environment list ───────────────────────────────── -TEST(BuildResources, EnvListSplitsOnSemicolonsOnly) { +UTest env_list_splits_on_semicolons_only{"BuildResources/EnvListSplitsOnSemicolonsOnly", [] { // The rc-tool search walks the toolchain's PATH override, which the MSVC // backend builds with `;` from real Windows paths. Splitting on ":" as well // cuts at the DRIVE COLON: `C:\...` becomes `C` plus a current-drive-relative @@ -381,33 +376,33 @@ TEST(BuildResources, EnvListSplitsOnSemicolonsOnly) { // became unfindable. auto v = res::split_env_list( R"(C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64;C:\VC\bin\Hostx64\x64)"); - ASSERT_EQ(v.size(), 2u); - EXPECT_EQ(v[0], R"(C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64)"); - EXPECT_EQ(v[1], R"(C:\VC\bin\Hostx64\x64)"); + expect(fatal(eq(v.size(), 2u))); + expect(eq(v[0], R"(C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64)")); + expect(eq(v[1], R"(C:\VC\bin\Hostx64\x64)")); // Empty entries (a trailing or doubled separator) are dropped rather than // becoming a probe of the current directory. auto e = res::split_env_list(R"(C:\a;;C:\b;)"); - ASSERT_EQ(e.size(), 2u); - EXPECT_EQ(e[0], R"(C:\a)"); - EXPECT_EQ(e[1], R"(C:\b)"); + expect(fatal(eq(e.size(), 2u))); + expect(eq(e[0], R"(C:\a)")); + expect(eq(e[1], R"(C:\b)")); - EXPECT_TRUE(res::split_env_list("").empty()); - EXPECT_EQ(res::split_env_list(R"(C:\only)").size(), 1u); -} + expect(res::split_env_list("").empty()); + expect(eq(res::split_env_list(R"(C:\only)").size(), 1u)); +}}; -TEST(BuildResources, ScanStaysQuietWhenTheMacroIsActuallyDefined) { +UTest scan_stays_quiet_when_the_macro_is_actually_defined{"BuildResources/ScanStaysQuietWhenTheMacroIsActuallyDefined", [] { TempDir d; // Either of these makes VS_VERSION_INFO real, so there is nothing to warn // about — the lint must not cry wolf at correct scripts. auto viaInclude = d.write("ok1.rc", "#include \n" "VS_VERSION_INFO VERSIONINFO\nBEGIN\nEND\n"); - EXPECT_FALSE(res::scan_rc(viaInclude).versionInfoNamedByString); + expect(!(res::scan_rc(viaInclude).versionInfoNamedByString)); auto viaDefine = d.write("ok2.rc", "#define VS_VERSION_INFO 1\n" "VS_VERSION_INFO VERSIONINFO\nBEGIN\nEND\n"); - EXPECT_FALSE(res::scan_rc(viaDefine).versionInfoNamedByString); + expect(!(res::scan_rc(viaDefine).versionInfoNamedByString)); auto literal = d.write("ok3.rc", "1 VERSIONINFO\nBEGIN\nEND\n"); - EXPECT_FALSE(res::scan_rc(literal).versionInfoNamedByString); -} + expect(!(res::scan_rc(literal).versionInfoNamedByString)); +}}; diff --git a/tests/unit/test_build_stage.cpp b/tests/unit/test_build_stage.cpp index 8823129e2..f8ba7d036 100644 --- a/tests/unit/test_build_stage.cpp +++ b/tests/unit/test_build_stage.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.source_kind; import mcpp.build.stage; +using namespace mcpplibs::mtest; + using namespace mcpp::build::stage; namespace { @@ -42,21 +44,21 @@ StageOptions no_retry(Verify v = Verify::Content) { } // namespace -TEST(BuildStage, CopiesWhenDestinationMissing) { +UTest copies_when_destination_missing{"BuildStage/CopiesWhenDestinationMissing", [] { Tmp tmp; auto src = tmp.path / "src.bin"; auto dst = tmp.path / "nested" / "dir" / "dst.bin"; write_file(src, "payload"); auto r = stage_file(src, dst, no_retry()); - ASSERT_TRUE(r.has_value()) << (r ? "" : r.error().message); - EXPECT_TRUE(r->copied); - EXPECT_EQ(read_file(dst), "payload"); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << (r ? "" : r.error().message); + expect(bool(r->copied)); + expect(eq(read_file(dst), "payload")); +}}; // The core of #311: an already-equivalent destination must not be written at // all — that is what keeps a clangd-mapped std.pcm from failing the build. -TEST(BuildStage, EquivalentDestinationIsNotTouched) { +UTest equivalent_destination_is_not_touched{"BuildStage/EquivalentDestinationIsNotTouched", [] { Tmp tmp; auto src = tmp.path / "src.bin"; auto dst = tmp.path / "dst.bin"; @@ -69,17 +71,17 @@ TEST(BuildStage, EquivalentDestinationIsNotTouched) { auto recorded = std::filesystem::last_write_time(dst); auto r = stage_file(src, dst, no_retry()); - ASSERT_TRUE(r.has_value()); - EXPECT_FALSE(r->copied); + expect(fatal(r.has_value())); + expect(!r->copied); // Not "not bumped to now" — not changed at all. Any mtime change defeats // ninja's `restat = 1` and re-triggers the downstream rebuild cascade. - EXPECT_EQ(std::filesystem::last_write_time(dst), recorded); -} + expect(eq(std::filesystem::last_write_time(dst), recorded)); +}}; // Same size, different bytes must be copied by DEFAULT. Staging also carries // .dll payloads, where PE section padding makes equal sizes across a real // rebuild ordinary — so size-only equivalence cannot be the default. -TEST(BuildStage, SameSizeDifferentBytesIsCopiedByDefault) { +UTest same_size_different_bytes_is_copied_by_default{"BuildStage/SameSizeDifferentBytesIsCopiedByDefault", [] { Tmp tmp; auto src = tmp.path / "src.bin"; auto dst = tmp.path / "dst.bin"; @@ -87,15 +89,15 @@ TEST(BuildStage, SameSizeDifferentBytesIsCopiedByDefault) { write_file(dst, "BBBB"); auto r = stage_file(src, dst, no_retry()); - ASSERT_TRUE(r.has_value()); - EXPECT_TRUE(r->copied); - EXPECT_EQ(read_file(dst), "AAAA"); -} + expect(fatal(r.has_value())); + expect(bool(r->copied)); + expect(eq(read_file(dst), "AAAA")); +}}; // `--verify size` does not make a readable destination the source's: a // package's BMI is staged at a path named by the module, so after an upgrade // the previous version's BMI is there, often at the same size. -TEST(BuildStage, SameSizeDifferentBytesIsCopiedUnderSizeVerify) { +UTest same_size_different_bytes_is_copied_under_size_verify{"BuildStage/SameSizeDifferentBytesIsCopiedUnderSizeVerify", [] { Tmp tmp; auto src = tmp.path / "src.bin"; auto dst = tmp.path / "dst.bin"; @@ -103,15 +105,15 @@ TEST(BuildStage, SameSizeDifferentBytesIsCopiedUnderSizeVerify) { write_file(dst, "BBBB"); auto r = stage_file(src, dst, no_retry(Verify::Size)); - ASSERT_TRUE(r.has_value()); - EXPECT_TRUE(r->copied); - EXPECT_EQ(read_file(dst), "AAAA"); -} + expect(fatal(r.has_value())); + expect(bool(r->copied)); + expect(eq(read_file(dst), "AAAA")); +}}; // A destination that cannot be opened is taken as staged under `--verify size` // when its size is the source's: a holder that denies reading it denies // replacing it as well. -TEST(BuildStage, AnUnreadableSameSizeDestinationIsSkippedUnderSizeVerify) { +UTest an_unreadable_same_size_destination_is_skipped_under_size_verify{"BuildStage/AnUnreadableSameSizeDestinationIsSkippedUnderSizeVerify", [] { Tmp tmp; auto src = tmp.path / "src.bin"; auto dst = tmp.path / "dst.bin"; @@ -120,18 +122,18 @@ TEST(BuildStage, AnUnreadableSameSizeDestinationIsSkippedUnderSizeVerify) { std::filesystem::permissions(dst, std::filesystem::perms::none); if (std::ifstream(dst, std::ios::binary).is_open()) { std::filesystem::permissions(dst, std::filesystem::perms::owner_all); - GTEST_SKIP() << "permissions do not deny reading here"; + skip("permissions do not deny reading here"); } auto r = stage_file(src, dst, no_retry(Verify::Size)); auto content = r.has_value() ? std::string() : r.error().message; std::filesystem::permissions(dst, std::filesystem::perms::owner_all); - ASSERT_TRUE(r.has_value()) << content; - EXPECT_FALSE(r->copied); - EXPECT_EQ(read_file(dst), "BBBB"); -} + expect(fatal(r.has_value())) << content; + expect(!r->copied); + expect(eq(read_file(dst), "BBBB")); +}}; -TEST(BuildStage, DifferentSizeIsAlwaysCopied) { +UTest different_size_is_always_copied{"BuildStage/DifferentSizeIsAlwaysCopied", [] { Tmp tmp; auto src = tmp.path / "src.bin"; auto dst = tmp.path / "dst.bin"; @@ -139,66 +141,66 @@ TEST(BuildStage, DifferentSizeIsAlwaysCopied) { write_file(dst, "short"); auto r = stage_file(src, dst, no_retry()); - ASSERT_TRUE(r.has_value()); - EXPECT_TRUE(r->copied); - EXPECT_EQ(read_file(dst), "longer-payload"); -} + expect(fatal(r.has_value())); + expect(bool(r->copied)); + expect(eq(read_file(dst), "longer-payload")); +}}; // Workspace design 2026-09-29 §5.3: a shared library placed in two product // directories holds one copy of its bytes where the file system allows a link; // any other file is copied, so a program that writes a file beside itself // never writes through to its source. -TEST(BuildStage, ASharedLibraryIsPlacedByALinkAndADataFileByACopy) { +UTest a_shared_library_is_placed_by_a_link_and_a_data_file_by_a_copy{"BuildStage/ASharedLibraryIsPlacedByALinkAndADataFileByACopy", [] { Tmp tmp; auto lib = tmp.path / "store" / "libfoo.so.1"; auto data = tmp.path / "store" / "table.dat"; write_file(lib, "library"); write_file(data, "data"); for (auto const& dir : {"cli", "gui"}) { - ASSERT_TRUE(stage_file(lib, tmp.path / "bin" / dir / "libfoo.so.1", no_retry())); - ASSERT_TRUE(stage_file(data, tmp.path / "bin" / dir / "table.dat", no_retry())); + expect(fatal(bool(stage_file(lib, tmp.path / "bin" / dir / "libfoo.so.1", no_retry())))); + expect(fatal(bool(stage_file(data, tmp.path / "bin" / dir / "table.dat", no_retry())))); } std::error_code ec; const auto libLinks = std::filesystem::hard_link_count(lib, ec); - ASSERT_FALSE(ec); + expect(fatal(!ec)); // Three names on a file system with links (the source and two // placements), one where the link fell back to a copy. - EXPECT_TRUE(libLinks == 3 || libLinks == 1) << libLinks; + expect(libLinks == 3 || libLinks == 1) << libLinks; if (libLinks == 3) - EXPECT_TRUE(std::filesystem::equivalent(tmp.path / "bin" / "cli" / "libfoo.so.1", - tmp.path / "bin" / "gui" / "libfoo.so.1")); - EXPECT_EQ(std::filesystem::hard_link_count(data), 1u); - EXPECT_EQ(read_file(tmp.path / "bin" / "gui" / "table.dat"), "data"); -} + expect(bool(std::filesystem::equivalent(tmp.path / "bin" / "cli" / "libfoo.so.1", + tmp.path / "bin" / "gui" / "libfoo.so.1"))); + expect(eq(std::filesystem::hard_link_count(data), 1u)); + expect(eq(read_file(tmp.path / "bin" / "gui" / "table.dat"), "data")); +}}; // A linked placement is replaced out of place: a new source never rewrites the // bytes the earlier source shares with the placement. -TEST(BuildStage, ReplacingALinkedPlacementLeavesTheEarlierSourceIntact) { +UTest replacing_a_linked_placement_leaves_the_earlier_source_intact{"BuildStage/ReplacingALinkedPlacementLeavesTheEarlierSourceIntact", [] { Tmp tmp; auto first = tmp.path / "v1" / "foo.dll"; auto second = tmp.path / "v2" / "foo.dll"; auto dst = tmp.path / "bin" / "app" / "foo.dll"; write_file(first, "version one"); write_file(second, "version two!"); - ASSERT_TRUE(stage_file(first, dst, no_retry())); - ASSERT_TRUE(stage_file(second, dst, no_retry())); - EXPECT_EQ(read_file(dst), "version two!"); - EXPECT_EQ(read_file(first), "version one"); -} + expect(fatal(bool(stage_file(first, dst, no_retry())))); + expect(fatal(bool(stage_file(second, dst, no_retry())))); + expect(eq(read_file(dst), "version two!")); + expect(eq(read_file(first), "version one")); +}}; -TEST(BuildStage, MissingSourceIsAnError) { +UTest missing_source_is_an_error{"BuildStage/MissingSourceIsAnError", [] { Tmp tmp; auto r = stage_file(tmp.path / "nope.bin", tmp.path / "dst.bin", no_retry()); - ASSERT_FALSE(r.has_value()); - EXPECT_NE(r.error().message.find("nope.bin"), std::string::npos); -} + expect(fatal(!r.has_value())); + expect(ne(r.error().message.find("nope.bin"), std::string::npos)); +}}; // A read-only destination that must actually be replaced: POSIX can do it // (the directory entry is what gets rewritten, so temp-file + rename wins), // Windows cannot (replacing a read-only file is denied however you spell it) — // and there the contract is the loud failure, not a silent skip. Asserted per // platform rather than skipped, so neither side can rot unnoticed. -TEST(BuildStage, ReadOnlyDestinationOutcomeIsPlatformDefined) { +UTest read_only_destination_outcome_is_platform_defined{"BuildStage/ReadOnlyDestinationOutcomeIsPlatformDefined", [] { Tmp tmp; auto src = tmp.path / "src.bin"; auto dst = tmp.path / "dst.bin"; @@ -208,19 +210,19 @@ TEST(BuildStage, ReadOnlyDestinationOutcomeIsPlatformDefined) { auto r = stage_file(src, dst, no_retry()); #if defined(_WIN32) - ASSERT_FALSE(r.has_value()); - EXPECT_NE(r.error().message.find("help:"), std::string::npos); - EXPECT_EQ(read_file(dst), "old"); + expect(fatal(!r.has_value())); + expect(ne(r.error().message.find("help:"), std::string::npos)); + expect(eq(read_file(dst), "old")); #else - ASSERT_TRUE(r.has_value()) << r.error().message; - EXPECT_TRUE(r->copied); - EXPECT_EQ(read_file(dst), "new-content"); + expect(fatal(r.has_value())) << r.error().message; + expect(bool(r->copied)); + expect(eq(read_file(dst), "new-content")); #endif -} +}}; // When staging genuinely cannot proceed, the failure must name the file and // carry the actionable hint — never be downgraded to a warning. -TEST(BuildStage, UnwritableDestinationFailsWithActionableHint) { +UTest unwritable_destination_fails_with_actionable_hint{"BuildStage/UnwritableDestinationFailsWithActionableHint", [] { Tmp tmp; auto src = tmp.path / "src.bin"; write_file(src, "payload"); @@ -230,13 +232,13 @@ TEST(BuildStage, UnwritableDestinationFailsWithActionableHint) { write_file(dst / "child" / "keep.txt", "x"); auto r = stage_file(src, dst, no_retry()); - ASSERT_FALSE(r.has_value()); - EXPECT_NE(r.error().message.find("help:"), std::string::npos); - EXPECT_NE(r.error().message.find("clangd"), std::string::npos); - EXPECT_NE(r.error().message.find(dst.string()), std::string::npos); -} + expect(fatal(!r.has_value())); + expect(ne(r.error().message.find("help:"), std::string::npos)); + expect(ne(r.error().message.find("clangd"), std::string::npos)); + expect(ne(r.error().message.find(dst.string()), std::string::npos)); +}}; -TEST(BuildStage, SameContentComparesBytesNotJustSize) { +UTest same_content_compares_bytes_not_just_size{"BuildStage/SameContentComparesBytesNotJustSize", [] { Tmp tmp; auto a = tmp.path / "a.bin"; auto b = tmp.path / "b.bin"; @@ -245,22 +247,22 @@ TEST(BuildStage, SameContentComparesBytesNotJustSize) { write_file(b, std::string(300000, 'x')); write_file(c, std::string(300000, 'x').replace(299999, 1, "y")); - EXPECT_TRUE(same_content(a, b)); - EXPECT_FALSE(same_content(a, c)); - EXPECT_FALSE(same_content(a, tmp.path / "missing.bin")); -} + expect(bool(same_content(a, b))); + expect(!same_content(a, c)); + expect(!same_content(a, tmp.path / "missing.bin")); +}}; -TEST(BuildStage, VerifyModeParsing) { - EXPECT_EQ(parse_verify("content"), Verify::Content); - EXPECT_EQ(parse_verify("size"), Verify::Size); +UTest verify_mode_parsing{"BuildStage/VerifyModeParsing", [] { + expect(eq(parse_verify("content"), Verify::Content)); + expect(eq(parse_verify("size"), Verify::Size)); // Anything unrecognized must land on the SAFE mode, not the fast one. - EXPECT_EQ(parse_verify("nonsense"), Verify::Content); - EXPECT_EQ(parse_verify(""), Verify::Content); -} + expect(eq(parse_verify("nonsense"), Verify::Content)); + expect(eq(parse_verify(""), Verify::Content)); +}}; // ── SPEC-007 R4.2 (mcpp#723): a destination with more than one source ────── -TEST(BuildStageFiles, OneSourceIsIdenticalToStageFile) { +UTest one_source_is_identical_to_stage_file{"BuildStageFiles/OneSourceIsIdenticalToStageFile", [] { // The whole point of `stage_files`: a project with exactly one source per // destination -- every project before this feature -- must not notice a // difference. This is not asserted by string-matching a ninja line (that @@ -272,12 +274,12 @@ TEST(BuildStageFiles, OneSourceIsIdenticalToStageFile) { write_file(src, "payload"); auto r = stage_files({src}, dst, no_retry()); - ASSERT_TRUE(r.has_value()) << r.error().message; - EXPECT_TRUE(r->copied); - EXPECT_EQ(read_file(dst), "payload"); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error().message; + expect(bool(r->copied)); + expect(eq(read_file(dst), "payload")); +}}; -TEST(BuildStageFiles, AgreeingSourcesArePlaced) { +UTest agreeing_sources_are_placed{"BuildStageFiles/AgreeingSourcesArePlaced", [] { Tmp tmp; auto a = tmp.path / "a.bin"; auto b = tmp.path / "b.bin"; @@ -288,12 +290,12 @@ TEST(BuildStageFiles, AgreeingSourcesArePlaced) { write_file(c, "shared bytes"); auto r = stage_files({a, b, c}, dst, no_retry()); - ASSERT_TRUE(r.has_value()) << r.error().message; - EXPECT_TRUE(r->copied); - EXPECT_EQ(read_file(dst), "shared bytes"); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error().message; + expect(bool(r->copied)); + expect(eq(read_file(dst), "shared bytes")); +}}; -TEST(BuildStageFiles, AnAlreadyEquivalentDestinationIsNotTouched) { +UTest an_already_equivalent_destination_is_not_touched{"BuildStageFiles/AnAlreadyEquivalentDestinationIsNotTouched", [] { // The #311 property (BuildStage.EquivalentDestinationIsNotTouched) must // survive going through the multi-source entry point too. Tmp tmp; @@ -308,12 +310,12 @@ TEST(BuildStageFiles, AnAlreadyEquivalentDestinationIsNotTouched) { auto recorded = std::filesystem::last_write_time(dst); auto r = stage_files({a, b}, dst, no_retry()); - ASSERT_TRUE(r.has_value()); - EXPECT_FALSE(r->copied); - EXPECT_EQ(std::filesystem::last_write_time(dst), recorded); -} + expect(fatal(r.has_value())); + expect(!r->copied); + expect(eq(std::filesystem::last_write_time(dst), recorded)); +}}; -TEST(BuildStageFiles, DisagreeingSourcesFailNamingEveryOneAndTheDestination) { +UTest disagreeing_sources_fail_naming_every_one_and_the_destination{"BuildStageFiles/DisagreeingSourcesFailNamingEveryOneAndTheDestination", [] { Tmp tmp; auto a = tmp.path / "qtbase_zh_CN.qm"; auto b = tmp.path / "other" / "qtbase_zh_CN.qm"; @@ -322,19 +324,16 @@ TEST(BuildStageFiles, DisagreeingSourcesFailNamingEveryOneAndTheDestination) { write_file(b, "catalog B"); auto r = stage_files({a, b}, dst, no_retry()); - ASSERT_FALSE(r.has_value()); - EXPECT_NE(r.error().message.find(a.string()), std::string::npos) - << r.error().message; - EXPECT_NE(r.error().message.find(b.string()), std::string::npos) - << r.error().message; - EXPECT_NE(r.error().message.find(dst.string()), std::string::npos) - << r.error().message; + expect(fatal(!r.has_value())); + if (auto&& failed = expect(ne(r.error().message.find(a.string()), std::string::npos)); !failed) failed << r.error().message; + if (auto&& failed = expect(ne(r.error().message.find(b.string()), std::string::npos)); !failed) failed << r.error().message; + if (auto&& failed = expect(ne(r.error().message.find(dst.string()), std::string::npos)); !failed) failed << r.error().message; // Refused, not silently resolved to either source: the destination is // untouched (no partial write from a rejected attempt). - EXPECT_FALSE(std::filesystem::exists(dst)); -} + expect(!std::filesystem::exists(dst)); +}}; -TEST(BuildStageFiles, ThreeSourcesWhereOnlyTheLastDisagreesAreAllNamed) { +UTest three_sources_where_only_the_last_disagrees_are_all_named{"BuildStageFiles/ThreeSourcesWhereOnlyTheLastDisagreesAreAllNamed", [] { // A pairwise comparison against only the first neighbour would miss a // mismatch between the second and third; every source must be compared. Tmp tmp; @@ -347,12 +346,11 @@ TEST(BuildStageFiles, ThreeSourcesWhereOnlyTheLastDisagreesAreAllNamed) { write_file(c, "Y"); auto r = stage_files({a, b, c}, dst, no_retry()); - ASSERT_FALSE(r.has_value()); - EXPECT_NE(r.error().message.find(c.string()), std::string::npos) - << r.error().message; -} + expect(fatal(!r.has_value())); + if (auto&& failed = expect(ne(r.error().message.find(c.string()), std::string::npos)); !failed) failed << r.error().message; +}}; -TEST(BuildStageFiles, AMissingSourceIsNamedAsMissingNotAsADisagreement) { +UTest a_missing_source_is_named_as_missing_not_as_a_disagreement{"BuildStageFiles/AMissingSourceIsNamedAsMissingNotAsADisagreement", [] { Tmp tmp; auto a = tmp.path / "a.bin"; auto b = tmp.path / "never-written.bin"; @@ -360,15 +358,15 @@ TEST(BuildStageFiles, AMissingSourceIsNamedAsMissingNotAsADisagreement) { write_file(a, "X"); auto r = stage_files({a, b}, dst, no_retry()); - ASSERT_FALSE(r.has_value()); - EXPECT_NE(r.error().message.find("does not exist"), std::string::npos) << r.error().message; - EXPECT_NE(r.error().message.find(b.string()), std::string::npos) << r.error().message; - EXPECT_EQ(r.error().message.find("disagree"), std::string::npos) << r.error().message; - EXPECT_FALSE(std::filesystem::exists(dst)); -} - -TEST(BuildStageFiles, EmptySourceListIsAnError) { + expect(fatal(!r.has_value())); + if (auto&& failed = expect(ne(r.error().message.find("does not exist"), std::string::npos)); !failed) failed << r.error().message; + if (auto&& failed = expect(ne(r.error().message.find(b.string()), std::string::npos)); !failed) failed << r.error().message; + if (auto&& failed = expect(eq(r.error().message.find("disagree"), std::string::npos)); !failed) failed << r.error().message; + expect(!std::filesystem::exists(dst)); +}}; + +UTest empty_source_list_is_an_error{"BuildStageFiles/EmptySourceListIsAnError", [] { Tmp tmp; auto r = stage_files({}, tmp.path / "dst.bin", no_retry()); - ASSERT_FALSE(r.has_value()); -} + expect(fatal(!r.has_value())); +}}; diff --git a/tests/unit/test_c_standard_per_package.cpp b/tests/unit/test_c_standard_per_package.cpp index a8bc1f41e..6ff1607af 100644 --- a/tests/unit/test_c_standard_per_package.cpp +++ b/tests/unit/test_c_standard_per_package.cpp @@ -9,9 +9,9 @@ // per-unit C flags, which the compile edge, the scan edge and both build // databases read after the file-level flags. -#include import std; +import mcpplibs.mtest; import mcpp.build.plan; import mcpp.manifest; import mcpp.modgraph.graph; @@ -19,6 +19,8 @@ import mcpp.modgraph.scanner; import mcpp.source_kind; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + using namespace mcpp::build; namespace { @@ -143,7 +145,7 @@ Result plan_for(const Fixture& f) { auto plan = make_plan(app, f.tc, {}, graph, topo, packages, appRoot, appRoot / "target" / "t", {}, {}, {}); - EXPECT_TRUE(plan.has_value()) << (plan ? "" : plan.error()); + if (auto&& failed = expect(plan.has_value()); !failed) failed << (plan ? "" : plan.error()); Result r; if (!plan) return r; for (auto const& cu : plan->compileUnits) { @@ -166,48 +168,48 @@ bool has_std(const std::vector& v) { // The report's case: a dependency that declares `gnu11` compiles its C units at // `gnu11`, and not at the consumer's standard. -TEST(CStandardPerPackage, ADependencyCompilesAtItsOwnStandard) { +UTest a_dependency_compiles_at_its_own_standard{"CStandardPerPackage/ADependencyCompilesAtItsOwnStandard", [] { auto r = plan_for(Fixture{}); - ASSERT_FALSE(r.cflags["cdep.c"].empty()); - EXPECT_EQ(r.cflags["cdep.c"].back(), "-std=gnu11"); - EXPECT_EQ(r.cflags["more.c"].back(), "-std=gnu11"); -} + expect(fatal(!(r.cflags["cdep.c"].empty()))); + expect(eq(r.cflags["cdep.c"].back(), "-std=gnu11")); + expect(eq(r.cflags["more.c"].back(), "-std=gnu11")); +}}; // The consumer's standard reaches the consumer's own C units and no one else's. -TEST(CStandardPerPackage, TheRootsStandardStaysInTheRoot) { +UTest the_roots_standard_stays_in_the_root{"CStandardPerPackage/TheRootsStandardStaysInTheRoot", [] { Fixture f; f.rootStandard = "c99"; auto r = plan_for(f); - ASSERT_FALSE(r.cflags["app.c"].empty()); - EXPECT_EQ(r.cflags["app.c"].back(), "-std=c99"); - EXPECT_FALSE(has(r.cflags["plain.c"], "-std=c99")); - EXPECT_FALSE(has_std(r.cflags["plain.c"])) + expect(fatal(!(r.cflags["app.c"].empty()))); + expect(eq(r.cflags["app.c"].back(), "-std=c99")); + expect(!has(r.cflags["plain.c"], "-std=c99")); + expect(!has_std(r.cflags["plain.c"])) << "a package that declares nothing compiles at the file-level default"; - EXPECT_EQ(r.cflags["cdep.c"].back(), "-std=gnu11"); -} + expect(eq(r.cflags["cdep.c"].back(), "-std=gnu11")); +}}; // A declaration equal to the default adds nothing: the file-level line already // carries it, so the command line is unchanged. -TEST(CStandardPerPackage, DeclaringTheDefaultAddsNothing) { +UTest declaring_the_default_adds_nothing{"CStandardPerPackage/DeclaringTheDefaultAddsNothing", [] { Fixture f; f.depStandard = "c11"; auto r = plan_for(f); - EXPECT_FALSE(has_std(r.cflags["cdep.c"])); -} + expect(!has_std(r.cflags["cdep.c"])); +}}; // A C standard is a C setting: no C++ unit receives one. -TEST(CStandardPerPackage, NoCxxUnitReceivesACStandard) { +UTest no_cxx_unit_receives_ac_standard{"CStandardPerPackage/NoCxxUnitReceivesACStandard", [] { Fixture f; f.rootStandard = "c99"; auto r = plan_for(f); - EXPECT_FALSE(has_std(r.cxxflags["main.cpp"])); - EXPECT_FALSE(has_std(r.cflags["main.cpp"])); -} + expect(!has_std(r.cxxflags["main.cpp"])); + expect(!has_std(r.cflags["main.cpp"])); +}}; // cl.exe compiles C in its default mode and takes no C `/std:` from mcpp yet // (W3b). Nothing is added to a unit, and every declaring package that has C // units is recorded once, so the backend can say so in one line. -TEST(CStandardPerPackage, ClRecordsWhatItDoesNotApply) { +UTest cl_records_what_it_does_not_apply{"CStandardPerPackage/ClRecordsWhatItDoesNotApply", [] { Fixture f; f.tc = cl_like(); f.rootStandard = "c11"; @@ -215,29 +217,29 @@ TEST(CStandardPerPackage, ClRecordsWhatItDoesNotApply) { auto r = plan_for(f); for (auto const& [file, v] : r.cflags) for (auto const& flag : v) - EXPECT_FALSE(flag.starts_with("-std=") || flag.starts_with("/std:c")) + expect(!(flag.starts_with("-std=") || flag.starts_with("/std:c"))) << file << ": " << flag; - EXPECT_EQ(r.notApplied, (std::vector{"app (c11)", "cdep (gnu11)"})) + expect(eq(r.notApplied, (std::vector{"app (c11)", "cdep (gnu11)"}))) << "a package with no C units (quiet) is not reported"; -} +}}; // With no cl.exe in the build, nothing is recorded as unapplied. -TEST(CStandardPerPackage, GnuDriversApplyEverythingTheyAreGiven) { +UTest gnu_drivers_apply_everything_they_are_given{"CStandardPerPackage/GnuDriversApplyEverythingTheyAreGiven", [] { Fixture f; f.rootStandard = "c99"; auto r = plan_for(f); - EXPECT_TRUE(r.notApplied.empty()); -} + expect(r.notApplied.empty()); +}}; // A target's entry `main` written in C is a unit of the root package: it // compiles at the root's standard, as a scanned C unit does. -TEST(CStandardPerPackage, ACEntryMainTakesItsPackagesStandard) { +UTest ac_entry_main_takes_its_packages_standard{"CStandardPerPackage/ACEntryMainTakesItsPackagesStandard", [] { Fixture f; f.rootStandard = "c99"; f.cEntry = true; auto r = plan_for(f); - ASSERT_TRUE(r.cflags.contains("entry.c")) + expect(fatal(r.cflags.contains("entry.c"))) << "the plan has no unit for the entry main"; - ASSERT_FALSE(r.cflags["entry.c"].empty()); - EXPECT_EQ(r.cflags["entry.c"].back(), "-std=c99"); -} + expect(fatal(!(r.cflags["entry.c"].empty()))); + expect(eq(r.cflags["entry.c"].back(), "-std=c99")); +}}; diff --git a/tests/unit/test_cache_key.cpp b/tests/unit/test_cache_key.cpp index 4044e91ab..30c2ca567 100644 --- a/tests/unit/test_cache_key.cpp +++ b/tests/unit/test_cache_key.cpp @@ -1,12 +1,14 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.cache_key; import nlohmann.json; import mcpp.manifest; import mcpp.modgraph.scanner; import mcpp.toolchain.detect; +using namespace mcpplibs::mtest; + namespace ck = mcpp::build::cache_key; namespace { @@ -55,45 +57,45 @@ mcpp::modgraph::PackageRoot rootAt(const std::filesystem::path& at) { // an entry. Axes A–F contain nothing about the consumer, so there is no input // this test could vary to demonstrate the old behaviour — the closest positive // statement is that a dependency's key is a pure function of its own axes. -TEST(CacheKey, IsStableAcrossCallsForTheSameInputs) { - EXPECT_EQ(ck::key_hex(axes(), pkg()), ck::key_hex(axes(), pkg())); -} +UTest is_stable_across_calls_for_the_same_inputs{"CacheKey/IsStableAcrossCallsForTheSameInputs", [] { + expect(eq(ck::key_hex(axes(), pkg()), ck::key_hex(axes(), pkg()))); +}}; -TEST(CacheKey, KeyIsSixteenHexChars) { +UTest key_is_sixteen_hex_chars{"CacheKey/KeyIsSixteenHexChars", [] { auto k = ck::key_hex(axes(), pkg()); - EXPECT_EQ(k.size(), 16u); + expect(eq(k.size(), 16u)); for (char c : k) - EXPECT_TRUE(std::isxdigit(static_cast(c))) << k; -} + expect(bool(std::isxdigit(static_cast(c)))) << k; +}}; // ── C axis: the profile. This is the one that was missing entirely, and the // reason a --release build could be served -O0 -g dependency objects. ──────── -TEST(CacheKey, ProfileOptLevelChangesTheKey) { +UTest profile_opt_level_changes_the_key{"CacheKey/ProfileOptLevelChangesTheKey", [] { auto a = axes(); auto b = axes(); b.optLevel = "0"; - EXPECT_NE(ck::key_hex(a, pkg()), ck::key_hex(b, pkg())); -} + expect(ne(ck::key_hex(a, pkg()), ck::key_hex(b, pkg()))); +}}; -TEST(CacheKey, ProfileDebugLtoStripEachChangeTheKey) { +UTest profile_debug_lto_strip_each_change_the_key{"CacheKey/ProfileDebugLtoStripEachChangeTheKey", [] { auto base = ck::key_hex(axes(), pkg()); - { auto b = axes(); b.debug = true; EXPECT_NE(ck::key_hex(b, pkg()), base); } - { auto b = axes(); b.lto = true; EXPECT_NE(ck::key_hex(b, pkg()), base); } - { auto b = axes(); b.strip = true; EXPECT_NE(ck::key_hex(b, pkg()), base); } -} + { auto b = axes(); b.debug = true; expect(ne(ck::key_hex(b, pkg()), base)); } + { auto b = axes(); b.lto = true; expect(ne(ck::key_hex(b, pkg()), base)); } + { auto b = axes(); b.strip = true; expect(ne(ck::key_hex(b, pkg()), base)); } +}}; // ── A axis: toolchain identity ─────────────────────────────────────────────── -TEST(CacheKey, ToolchainIdentityChangesTheKey) { +UTest toolchain_identity_changes_the_key{"CacheKey/ToolchainIdentityChangesTheKey", [] { auto base = ck::key_hex(axes(), pkg()); - { auto b = axes(); b.compilerId = "clang"; EXPECT_NE(ck::key_hex(b, pkg()), base); } - { auto b = axes(); b.compilerVersion = "15.1.0"; EXPECT_NE(ck::key_hex(b, pkg()), base); } + { auto b = axes(); b.compilerId = "clang"; expect(ne(ck::key_hex(b, pkg()), base)); } + { auto b = axes(); b.compilerVersion = "15.1.0"; expect(ne(ck::key_hex(b, pkg()), base)); } { auto b = axes(); b.targetTriple = "x86_64-linux-musl"; - EXPECT_NE(ck::key_hex(b, pkg()), base); } - { auto b = axes(); b.stdlibId = "libc++"; EXPECT_NE(ck::key_hex(b, pkg()), base); } + expect(ne(ck::key_hex(b, pkg()), base)); } + { auto b = axes(); b.stdlibId = "libc++"; expect(ne(ck::key_hex(b, pkg()), base)); } // Two payloads that both call themselves "gcc 16.1.0" but are not the same // build must not share an entry. { auto b = axes(); b.driverIdentity = "ffffffffffffffff"; - EXPECT_NE(ck::key_hex(b, pkg()), base); } -} + expect(ne(ck::key_hex(b, pkg()), base)); } +}}; // The axis that exists because an UPGRADE broke a build. // @@ -107,135 +109,135 @@ TEST(CacheKey, ToolchainIdentityChangesTheKey) { // 'mcpplibs.riscv_virt_rt.pcm' but is currently disabled // // Measured on exactly that upgrade, on a project that had built once before. -TEST(CacheKey, TargetImpliedFlagsChangeTheKey) { +UTest target_implied_flags_change_the_key{"CacheKey/TargetImpliedFlagsChangeTheKey", [] { auto base = ck::key_hex(axes(), pkg()); { auto b = axes(); b.targetImpliedFlags = {"-ffreestanding"}; - EXPECT_NE(ck::key_hex(b, pkg()), base); } + expect(ne(ck::key_hex(b, pkg()), base)); } // Adding one flag to an existing set must move it too — the upgrade case // is never "empty becomes non-empty", it is "one more flag than before". auto before = axes(); before.targetImpliedFlags = {"-ffreestanding", "-nostdinc++"}; auto after = axes(); after.targetImpliedFlags = {"-ffreestanding", "-nostdinc++", "-fno-exceptions"}; - EXPECT_NE(ck::key_hex(after, pkg()), ck::key_hex(before, pkg())); -} + expect(ne(ck::key_hex(after, pkg()), ck::key_hex(before, pkg()))); +}}; // ── B axis: language and dialect ───────────────────────────────────────────── -TEST(CacheKey, LanguageAndDialectChangeTheKey) { +UTest language_and_dialect_change_the_key{"CacheKey/LanguageAndDialectChangeTheKey", [] { auto base = ck::key_hex(axes(), pkg()); - { auto b = axes(); b.cppStandard = "c++26"; EXPECT_NE(ck::key_hex(b, pkg()), base); } + { auto b = axes(); b.cppStandard = "c++26"; expect(ne(ck::key_hex(b, pkg()), base)); } { auto b = axes(); b.cppStandardFlag = "-std=c++2c"; - EXPECT_NE(ck::key_hex(b, pkg()), base); } + expect(ne(ck::key_hex(b, pkg()), base)); } // A dependency built at c++20 must never be handed to a c++23 graph: BMIs // are not compatible across levels (GCC: "language dialect differs"). { auto b = axes(); b.cppStandard = "c++20"; - b.cppStandardFlag = "-std=c++20"; EXPECT_NE(ck::key_hex(b, pkg()), base); } + b.cppStandardFlag = "-std=c++20"; expect(ne(ck::key_hex(b, pkg()), base)); } { auto b = axes(); b.dialectFlags = {"-freflection"}; - EXPECT_NE(ck::key_hex(b, pkg()), base); } - { auto b = axes(); b.cStandard = "c17"; EXPECT_NE(ck::key_hex(b, pkg()), base); } + expect(ne(ck::key_hex(b, pkg()), base)); } + { auto b = axes(); b.cStandard = "c17"; expect(ne(ck::key_hex(b, pkg()), base)); } // ONE SLOT, BOTH PLATFORMS. It was `macosDeploymentTarget`; Android's // minimum API level is the same quantity and is in this key for the same // reason -- it selects which bionic symbols are visible, so two levels are // two ABIs. A target is either Apple or Android, so one slot cannot be // asked to hold both at once. { auto b = axes(); b.minPlatformVersion = "14.0"; - EXPECT_NE(ck::key_hex(b, pkg()), base); } + expect(ne(ck::key_hex(b, pkg()), base)); } { auto b = axes(); b.minPlatformVersion = "24"; - EXPECT_NE(ck::key_hex(b, pkg()), base); } + expect(ne(ck::key_hex(b, pkg()), base)); } // AND TWO LEVELS ARE TWO KEYS, which is the property the Android row // depends on: without it `min_api_level = 21` and `= 24` would share a // build directory and the second build would serve the first's objects. { auto b21 = axes(); b21.minPlatformVersion = "21"; auto b24 = axes(); b24.minPlatformVersion = "24"; - EXPECT_NE(ck::key_hex(b21, pkg()), ck::key_hex(b24, pkg())); } -} + expect(ne(ck::key_hex(b21, pkg()), ck::key_hex(b24, pkg()))); } +}}; // ── D axis: package identity ───────────────────────────────────────────────── -TEST(CacheKey, PackageIdentityChangesTheKey) { +UTest package_identity_changes_the_key{"CacheKey/PackageIdentityChangesTheKey", [] { auto base = ck::key_hex(axes(), pkg()); - { auto p = pkg(); p.version = "1.3.1"; EXPECT_NE(ck::key_hex(axes(), p), base); } + { auto p = pkg(); p.version = "1.3.1"; expect(ne(ck::key_hex(axes(), p), base)); } { auto p = pkg(); p.packageName = "compat.zstd"; - EXPECT_NE(ck::key_hex(axes(), p), base); } - { auto p = pkg(); p.indexName = "other"; EXPECT_NE(ck::key_hex(axes(), p), base); } -} + expect(ne(ck::key_hex(axes(), p), base)); } + { auto p = pkg(); p.indexName = "other"; expect(ne(ck::key_hex(axes(), p), base)); } +}}; // ── E axis: the package's own config ───────────────────────────────────────── -TEST(CacheKey, OwnBuildConfigChangesTheKey) { +UTest own_build_config_changes_the_key{"CacheKey/OwnBuildConfigChangesTheKey", [] { auto base = ck::key_hex(axes(), pkg()); - { auto p = pkg(); p.cflags.push_back("-DEXTRA"); EXPECT_NE(ck::key_hex(axes(), p), base); } - { auto p = pkg(); p.cxxflags = {"-fno-rtti"}; EXPECT_NE(ck::key_hex(axes(), p), base); } - { auto p = pkg(); p.defines = {"FOO=1"}; EXPECT_NE(ck::key_hex(axes(), p), base); } - { auto p = pkg(); p.globFlags = {"glob:*.c"}; EXPECT_NE(ck::key_hex(axes(), p), base); } - { auto p = pkg(); p.generatedFiles = {"cfg.h=1"};EXPECT_NE(ck::key_hex(axes(), p), base); } + { auto p = pkg(); p.cflags.push_back("-DEXTRA"); expect(ne(ck::key_hex(axes(), p), base)); } + { auto p = pkg(); p.cxxflags = {"-fno-rtti"}; expect(ne(ck::key_hex(axes(), p), base)); } + { auto p = pkg(); p.defines = {"FOO=1"}; expect(ne(ck::key_hex(axes(), p), base)); } + { auto p = pkg(); p.globFlags = {"glob:*.c"}; expect(ne(ck::key_hex(axes(), p), base)); } + { auto p = pkg(); p.generatedFiles = {"cfg.h=1"};expect(ne(ck::key_hex(axes(), p), base)); } { auto p = pkg(); p.includeDirs = {"pub:/x"}; - EXPECT_NE(ck::key_hex(axes(), p), base); } + expect(ne(ck::key_hex(axes(), p), base)); } { auto p = pkg(); p.sources.push_back("zlib-1.3.2/gzread.c"); - EXPECT_NE(ck::key_hex(axes(), p), base); } - { auto p = pkg(); p.features = {"main"}; EXPECT_NE(ck::key_hex(axes(), p), base); } -} + expect(ne(ck::key_hex(axes(), p), base)); } + { auto p = pkg(); p.features = {"main"}; expect(ne(ck::key_hex(axes(), p), base)); } +}}; // ── F axis: upstream keys (Merkle) ─────────────────────────────────────────── // A dependency's artifacts are bound to the exact upstream artifacts they were // built against — GCC embeds a CRC of an imported module's BMI into the // importer's BMI, so a stale importer BMI fails with `module 'X' CRC mismatch`. // The key therefore folds in the upstream's key, not a summary of its interface. -TEST(CacheKey, UpstreamKeyChangesTheKey) { +UTest upstream_key_changes_the_key{"CacheKey/UpstreamKeyChangesTheKey", [] { auto base = ck::key_hex(axes(), pkg()); auto p = pkg(); p.upstreamKeys = {"1111111111111111"}; auto withUp = ck::key_hex(axes(), p); - EXPECT_NE(withUp, base); + expect(ne(withUp, base)); p.upstreamKeys = {"2222222222222222"}; - EXPECT_NE(ck::key_hex(axes(), p), withUp); -} + expect(ne(ck::key_hex(axes(), p), withUp)); +}}; -TEST(CacheKey, UpstreamOrderDoesNotMatterWhenSorted) { +UTest upstream_order_does_not_matter_when_sorted{"CacheKey/UpstreamOrderDoesNotMatterWhenSorted", [] { auto p1 = pkg(); p1.upstreamKeys = {"1111111111111111", "2222222222222222"}; auto p2 = pkg(); p2.upstreamKeys = {"1111111111111111", "2222222222222222"}; - EXPECT_EQ(ck::key_hex(axes(), p1), ck::key_hex(axes(), p2)); -} + expect(eq(ck::key_hex(axes(), p1), ck::key_hex(axes(), p2))); +}}; // Length-prefixed field joining: without it, ("a","bc") and ("ab","c") collide, // and for a cache key a collision means serving one package's objects for // another's. -TEST(CacheKey, AdjacentFieldsCannotCollide) { +UTest adjacent_fields_cannot_collide{"CacheKey/AdjacentFieldsCannotCollide", [] { auto p1 = pkg(); p1.packageName = "a"; p1.version = "bc"; auto p2 = pkg(); p2.packageName = "ab"; p2.version = "c"; - EXPECT_NE(ck::key_hex(axes(), p1), ck::key_hex(axes(), p2)); -} + expect(ne(ck::key_hex(axes(), p1), ck::key_hex(axes(), p2))); +}}; -TEST(CacheKey, ListBoundariesCannotCollide) { +UTest list_boundaries_cannot_collide{"CacheKey/ListBoundariesCannotCollide", [] { auto p1 = pkg(); p1.cflags = {"-DA", "-DB"}; auto p2 = pkg(); p2.cflags = {"-DA-DB"}; - EXPECT_NE(ck::key_hex(axes(), p1), ck::key_hex(axes(), p2)); -} + expect(ne(ck::key_hex(axes(), p1), ck::key_hex(axes(), p2))); +}}; // entry.json carries these inputs verbatim so a hit can be validated field by // field. Anything in the key must therefore also be in the JSON, or validation // would pass on an input the key distinguishes. -TEST(CacheKey, JsonCarriesEveryAxis) { +UTest json_carries_every_axis{"CacheKey/JsonCarriesEveryAxis", [] { auto j = ck::to_json(axes(), pkg()); - EXPECT_EQ(j["epoch"], ck::kCacheEpoch); + expect(eq(j["epoch"], ck::kCacheEpoch)); for (auto k : {"toolchain", "language", "profile", "package", "config", "upstream"}) - EXPECT_TRUE(j.contains(k)) << k; - EXPECT_EQ(j["toolchain"]["compiler"], "gcc"); - EXPECT_EQ(j["profile"]["opt_level"], "2"); - EXPECT_EQ(j["package"]["version"], "1.3.2"); + expect(j.contains(k)) << k; + expect(eq(j["toolchain"]["compiler"], "gcc")); + expect(eq(j["profile"]["opt_level"], "2")); + expect(eq(j["package"]["version"], "1.3.2")); for (auto k : {"features", "cflags", "cxxflags", "ldflags", "defines", "glob_flags", "generated_files", "include_dirs", "source_globs", "sources"}) - EXPECT_TRUE(j["config"].contains(k)) << k; -} + expect(j["config"].contains(k)) << k; +}}; -TEST(CacheKey, JsonDiffersWheneverTheKeyDiffers) { +UTest json_differs_whenever_the_key_differs{"CacheKey/JsonDiffersWheneverTheKeyDiffers", [] { auto b = axes(); b.optLevel = "0"; - EXPECT_NE(ck::to_json(axes(), pkg()), ck::to_json(b, pkg())); -} + expect(ne(ck::to_json(axes(), pkg()), ck::to_json(b, pkg()))); +}}; // Payload include dirs are absolute paths under /registry/data/xpkgs. // Left absolute, every entry would miss after a home relocation or on another // machine — which is most of the point of a cross-project cache. -TEST(CacheKey, IncludeDirsAreRelativizedAgainstTheStoreRoot) { +UTest include_dirs_are_relativized_against_the_store_root{"CacheKey/IncludeDirsAreRelativizedAgainstTheStoreRoot", [] { auto makeKeyFor = [](const std::filesystem::path& store) { auto pkgRoot = rootAt(store / "compat-x-compat.zlib" / "1.3.2"); pkgRoot.manifest.buildConfig.includeDirs = { @@ -248,26 +250,26 @@ TEST(CacheKey, IncludeDirsAreRelativizedAgainstTheStoreRoot) { }; auto [k1, dirs1] = makeKeyFor("/home/alice/.mcpp/registry/data/xpkgs"); auto [k2, dirs2] = makeKeyFor("/opt/ci/mcpp/registry/data/xpkgs"); - EXPECT_EQ(k1, k2); - ASSERT_FALSE(dirs1.empty()); - EXPECT_NE(dirs1.front().find(""), std::string::npos) << dirs1.front(); - EXPECT_EQ(dirs1, dirs2); -} + expect(eq(k1, k2)); + expect(fatal(!dirs1.empty())); + expect(ne(dirs1.front().find(""), std::string::npos)) << dirs1.front(); + expect(eq(dirs1, dirs2)); +}}; // A path that is under the package root but not under the store (a generated // directory, say) gets a package-relative placeholder for the same reason. -TEST(CacheKey, PackageRootRelativeDirsAreAlsoRelativized) { +UTest package_root_relative_dirs_are_also_relativized{"CacheKey/PackageRootRelativeDirsAreAlsoRelativized", [] { std::filesystem::path store = "/home/u/.mcpp/registry/data/xpkgs"; auto pkgRoot = rootAt("/somewhere/else/zlib"); pkgRoot.manifest.buildConfig.includeDirs = {"/somewhere/else/zlib/generated"}; ck::PackageAxes p; ck::fill_package_config(p, pkgRoot, store); - ASSERT_FALSE(p.includeDirs.empty()); - EXPECT_NE(p.includeDirs.front().find(""), std::string::npos) + expect(fatal(!p.includeDirs.empty())); + expect(ne(p.includeDirs.front().find(""), std::string::npos)) << p.includeDirs.front(); -} +}}; -TEST(CacheKey, FillPackageConfigCarriesFlagsAndGeneratedFiles) { +UTest fill_package_config_carries_flags_and_generated_files{"CacheKey/FillPackageConfigCarriesFlagsAndGeneratedFiles", [] { std::filesystem::path store = "/home/u/.mcpp/registry/data/xpkgs"; auto pkgRoot = rootAt(store / "compat-x-compat.zlib" / "1.3.2"); pkgRoot.manifest.buildConfig.cflags = {"-D_GNU_SOURCE"}; @@ -278,22 +280,22 @@ TEST(CacheKey, FillPackageConfigCarriesFlagsAndGeneratedFiles) { ck::PackageAxes p; ck::fill_package_config(p, pkgRoot, store); - EXPECT_NE(std::ranges::find(p.cflags, "-D_GNU_SOURCE"), p.cflags.end()); - EXPECT_NE(std::ranges::find(p.cxxflags, "-fno-exceptions"), p.cxxflags.end()); - EXPECT_NE(std::ranges::find(p.defines, "ZLIB_CONST"), p.defines.end()); - ASSERT_EQ(p.generatedFiles.size(), 1u); - EXPECT_EQ(p.generatedFiles.front(), "cfg.h=#define A 1"); + expect(ne(std::ranges::find(p.cflags, "-D_GNU_SOURCE"), p.cflags.end())); + expect(ne(std::ranges::find(p.cxxflags, "-fno-exceptions"), p.cxxflags.end())); + expect(ne(std::ranges::find(p.defines, "ZLIB_CONST"), p.defines.end())); + expect(fatal(eq(p.generatedFiles.size(), 1u))); + expect(eq(p.generatedFiles.front(), "cfg.h=#define A 1")); // A package's own C standard reaches its own C units, so it must be in the // key; the B axis carries only the engine default (#695). bool sawCStd = false; for (auto& f : p.cflags) if (f.find("c_standard=gnu11") != std::string::npos) sawCStd = true; - EXPECT_TRUE(sawCStd); -} + expect(bool(sawCStd)); +}}; // Declaring the default is declaring nothing: the unit's command is the same, // so the key is the same, and a package that spells `c11` shares its cached // objects with one that leaves the key out (#695). -TEST(CacheKey, DeclaringTheDefaultCStandardKeysNothing) { +UTest declaring_the_default_c_standard_keys_nothing{"CacheKey/DeclaringTheDefaultCStandardKeysNothing", [] { std::filesystem::path store = "/home/u/.mcpp/registry/data/xpkgs"; auto declared = rootAt(store / "compat-x-compat.zlib" / "1.3.2"); declared.manifest.buildConfig.cStandard = "c11"; @@ -302,12 +304,12 @@ TEST(CacheKey, DeclaringTheDefaultCStandardKeysNothing) { ck::PackageAxes a, b; ck::fill_package_config(a, declared, store); ck::fill_package_config(b, silent, store); - EXPECT_EQ(a.cflags, b.cflags); - for (auto& f : a.cflags) EXPECT_EQ(f.find("c_standard="), std::string::npos) << f; -} + expect(eq(a.cflags, b.cflags)); + for (auto& f : a.cflags) expect(eq(f.find("c_standard="), std::string::npos)) << f; +}}; // Generated files are a map; iteration order must not leak into the key. -TEST(CacheKey, GeneratedFilesAreOrderIndependent) { +UTest generated_files_are_order_independent{"CacheKey/GeneratedFilesAreOrderIndependent", [] { std::filesystem::path store = "/home/u/.mcpp/registry/data/xpkgs"; auto build = [&](std::vector> entries) { auto pkgRoot = rootAt(store / "p" / "1"); @@ -317,9 +319,8 @@ TEST(CacheKey, GeneratedFilesAreOrderIndependent) { ck::fill_package_config(p, pkgRoot, store); return ck::key_hex(axes(), p); }; - EXPECT_EQ(build({{"a.h", "1"}, {"b.h", "2"}}), - build({{"b.h", "2"}, {"a.h", "1"}})); -} + expect(eq(build({{"a.h", "1"}, {"b.h", "2"}}), build({{"b.h", "2"}, {"a.h", "1"}}))); +}}; // ── The key must describe the compilation that will happen ────────────────── // @@ -359,43 +360,42 @@ mcpp::toolchain::Toolchain freestanding_tc(bool cxxRuntimeFromGraph) { } } -TEST(CacheKey, AGraphSuppliedCxxRuntimeChangesTheFreestandingFlags) { +UTest a_graph_supplied_cxx_runtime_changes_the_freestanding_flags{"CacheKey/AGraphSuppliedCxxRuntimeChangesTheFreestandingFlags", [] { mcpp::manifest::Manifest m; m.package.standard = "c++23"; const auto without = ck::build_axes(freestanding_tc(false), m, "-std=c++23", {}, ""); const auto with = ck::build_axes(freestanding_tc(true), m, "-std=c++23", {}, ""); // The flags themselves differ — this is the fact the key has to carry. - EXPECT_NE(without.targetImpliedFlags, with.targetImpliedFlags); + expect(ne(without.targetImpliedFlags, with.targetImpliedFlags)); // And the one that has no C++ runtime is the one that gets the pair. const auto has = [](const std::vector& v, std::string_view f) { return std::ranges::find(v, f) != v.end(); }; - EXPECT_TRUE(has(without.targetImpliedFlags, "-fno-exceptions")); - EXPECT_FALSE(has(with.targetImpliedFlags, "-fno-exceptions")); -} + expect(bool(has(without.targetImpliedFlags, "-fno-exceptions"))); + expect(!has(with.targetImpliedFlags, "-fno-exceptions")); +}}; -TEST(CacheKey, TheTwoFreestandingConfigurationsDoNotShareASlot) { +UTest the_two_freestanding_configurations_do_not_share_a_slot{"CacheKey/TheTwoFreestandingConfigurationsDoNotShareASlot", [] { mcpp::manifest::Manifest m; m.package.standard = "c++23"; - EXPECT_NE(ck::key_hex(ck::build_axes(freestanding_tc(false), m, "-std=c++23", {}, ""), pkg()), - ck::key_hex(ck::build_axes(freestanding_tc(true), m, "-std=c++23", {}, ""), pkg())); -} + expect(ne(ck::key_hex(ck::build_axes(freestanding_tc(false), m, "-std=c++23", {}, ""), pkg()), ck::key_hex(ck::build_axes(freestanding_tc(true), m, "-std=c++23", {}, ""), pkg()))); +}}; // AND A HOSTED TARGET IS UNAFFECTED, so the fix cannot be read as "the key // now changes with something it should not". `freestanding::resolve` returns // nothing for a hosted triple, and the flags stay empty either way. -TEST(CacheKey, AHostedTargetHasNoTargetImpliedFlagsEitherWay) { +UTest a_hosted_target_has_no_target_implied_flags_either_way{"CacheKey/AHostedTargetHasNoTargetImpliedFlagsEitherWay", [] { mcpp::manifest::Manifest m; m.package.standard = "c++23"; auto tc = freestanding_tc(false); tc.targetTriple = "x86_64-linux-gnu"; auto a = ck::build_axes(tc, m, "-std=c++23", {}, ""); tc.targetCxxRuntime = true; auto b = ck::build_axes(tc, m, "-std=c++23", {}, ""); - EXPECT_TRUE(a.targetImpliedFlags.empty()) << a.targetImpliedFlags.size(); - EXPECT_EQ(a.targetImpliedFlags, b.targetImpliedFlags); -} + expect(a.targetImpliedFlags.empty()) << a.targetImpliedFlags.size(); + expect(eq(a.targetImpliedFlags, b.targetImpliedFlags)); +}}; // THE SAME CLASS OF DEFECT AS THE FREESTANDING ONE ABOVE, FOUND THE SAME WAY // — A CONFIG BIT THAT REACHES THE COMPILE BUT NOT THE KEY. @@ -419,7 +419,7 @@ TEST(CacheKey, AHostedTargetHasNoTargetImpliedFlagsEitherWay) { // exists to protect. `--cache=off`, or clearing the cache directory, // produced the correct numbers reproducibly — because both routes bypass // this key entirely, not because the key was telling the two apart. -TEST(CacheKey, TwoDifferentRealisedCEnvironmentsDoNotShareASlot) { +UTest two_different_realised_c_environments_do_not_share_a_slot{"CacheKey/TwoDifferentRealisedCEnvironmentsDoNotShareASlot", [] { std::filesystem::path store = "/home/u/.mcpp/registry/data/xpkgs"; auto build = [&](std::vector cEnvTokens) { auto pkgRoot = rootAt(store / "musl-x-openkal-musl" / "1.0.0"); @@ -441,8 +441,8 @@ TEST(CacheKey, TwoDifferentRealisedCEnvironmentsDoNotShareASlot) { }; auto lp64Key = build({"--target=x86_64-pc-cygwin", "-fno-short-wchar"}); auto llp64Key = build({}); - EXPECT_NE(lp64Key, llp64Key); -} + expect(ne(lp64Key, llp64Key)); +}}; // A DURABLE GUARD FOR THE WHOLE CLASS THE PREVIOUS TEST FOUND ONE INSTANCE // OF — this predates the c-abi wave (`targetSideUsage`'s own broadcast, and @@ -472,7 +472,7 @@ TEST(CacheKey, TwoDifferentRealisedCEnvironmentsDoNotShareASlot) { // day something does broadcast into either, this pair of assertions must // flip (start reading it) at the same time the broadcast is added, not // after. -TEST(CacheKey, EveryPrivateBuildBroadcastFieldReachesTheKey) { +UTest every_private_build_broadcast_field_reaches_the_key{"CacheKey/EveryPrivateBuildBroadcastFieldReachesTheKey", [] { std::filesystem::path store = "/home/u/.mcpp/registry/data/xpkgs"; auto keyFor = [&](auto mutate) { auto pkgRoot = rootAt(store / "p" / "1"); @@ -485,34 +485,34 @@ TEST(CacheKey, EveryPrivateBuildBroadcastFieldReachesTheKey) { }; const auto baseline = keyFor([](auto&) {}); - EXPECT_NE(baseline, keyFor([](auto& r) { + expect(ne(baseline, keyFor([](auto& r) { r.privateBuild.includeDirs = {"/somewhere/broadcast-only-include"}; - })) << "privateBuild.includeDirs"; - EXPECT_NE(baseline, keyFor([](auto& r) { + }))) << "privateBuild.includeDirs"; + expect(ne(baseline, keyFor([](auto& r) { r.privateBuild.includeDirsAfter = {"/somewhere/broadcast-only-after"}; - })) << "privateBuild.includeDirsAfter"; - EXPECT_NE(baseline, keyFor([](auto& r) { + }))) << "privateBuild.includeDirsAfter"; + expect(ne(baseline, keyFor([](auto& r) { r.privateBuild.cflags = {"-Dbroadcast_only_c"}; - })) << "privateBuild.cflags"; - EXPECT_NE(baseline, keyFor([](auto& r) { + }))) << "privateBuild.cflags"; + expect(ne(baseline, keyFor([](auto& r) { r.privateBuild.cxxflags = {"-Dbroadcast_only_cxx"}; - })) << "privateBuild.cxxflags"; - EXPECT_NE(baseline, keyFor([](auto& r) { + }))) << "privateBuild.cxxflags"; + expect(ne(baseline, keyFor([](auto& r) { r.privateBuild.asmflags = {"-Dbroadcast_only_asm"}; - })) << "privateBuild.asmflags"; + }))) << "privateBuild.asmflags"; // Documented gap, not an oversight — see the comment above. - EXPECT_EQ(baseline, keyFor([](auto& r) { + expect(eq(baseline, keyFor([](auto& r) { r.privateBuild.ldflags = {"-Wl,--broadcast-only"}; - })) << "privateBuild.ldflags is not yet a live broadcast channel; if " + }))) << "privateBuild.ldflags is not yet a live broadcast channel; if " "this starts failing, something now writes it, and " "fill_package_config must be taught to read it in the SAME change " "that flips this expectation"; - EXPECT_EQ(baseline, keyFor([](auto& r) { + expect(eq(baseline, keyFor([](auto& r) { r.privateBuild.modules = {"broadcast.only.module"}; - })) << "privateBuild.modules is not yet a live broadcast channel; see " + }))) << "privateBuild.modules is not yet a live broadcast channel; see " "the ldflags case above for what to do when it becomes one"; -} +}}; // THE HEADER SET THE DRIVER IS POINTED AT IS PART OF THE IDENTITY. // @@ -526,7 +526,7 @@ TEST(CacheKey, EveryPrivateBuildBroadcastFieldReachesTheKey) { // Measured (mcpp#514 §B): two host builds resolving glibc 2.39 and 2.44 hit one // entry, and the mixed BMIs crashed the clang frontend during deserialization // rather than producing a diagnostic. -TEST(CacheKey, TheHeaderSetIsPartOfTheKey) { +UTest the_header_set_is_part_of_the_key{"CacheKey/TheHeaderSetIsPartOfTheKey", [] { mcpp::manifest::Manifest m; m.package.standard = "c++23"; @@ -538,23 +538,22 @@ TEST(CacheKey, TheHeaderSetIsPartOfTheKey) { b.targetHeaderSet = { "-isystem/xim-x-glibc/2.44/include" }; a.targetHeaderSet = { "-isystem/xim-x-glibc/2.39/include" }; - EXPECT_NE(ck::key_hex(a, pkg()), ck::key_hex(b, pkg())); -} + expect(ne(ck::key_hex(a, pkg()), ck::key_hex(b, pkg()))); +}}; // ...and it travels in entry.json, so a suspected wrong hit can be read field // by field rather than guessed at. `is_cached` compares `inputs.toolchain` as // one object, which is also why an entry written before this axis existed is a // miss without needing `kCacheEpoch` to move. -TEST(CacheKey, TheHeaderSetIsRecordedInTheEntry) { +UTest the_header_set_is_recorded_in_the_entry{"CacheKey/TheHeaderSetIsRecordedInTheEntry", [] { mcpp::manifest::Manifest m; m.package.standard = "c++23"; auto a = ck::build_axes(freestanding_tc(false), m, "-std=c++23", {}, ""); a.targetHeaderSet = { "-isystem/xim-x-glibc/2.44/include" }; auto j = ck::to_json(a, pkg()); - ASSERT_TRUE(j["toolchain"].contains("target_header_set")); - EXPECT_EQ(j["toolchain"]["target_header_set"][0], - "-isystem/xim-x-glibc/2.44/include"); -} + expect(fatal(j["toolchain"].contains("target_header_set"))); + expect(eq(j["toolchain"]["target_header_set"][0], "-isystem/xim-x-glibc/2.44/include")); +}}; // AND THE PATHS IN IT ARE RELATIVE — INCLUDING THE ONES OUTSIDE ``. // @@ -573,7 +572,7 @@ TEST(CacheKey, TheHeaderSetIsRecordedInTheEntry) { // THE CRITERION IS THE ABSENCE OF THE HOME, not the presence of a tag. // Asserting `starts_with("")` would still pass if the rest of the string // carried the absolute path behind it. -TEST(CacheKey, TheHeaderSetCarriesNoAbsoluteHome) { +UTest the_header_set_carries_no_absolute_home{"CacheKey/TheHeaderSetCarriesNoAbsoluteHome", [] { const std::filesystem::path home = "/home/somebody/.mcpp/registry"; const std::filesystem::path store = home / "data" / "xpkgs"; @@ -590,18 +589,18 @@ TEST(CacheKey, TheHeaderSetCarriesNoAbsoluteHome) { // A denominator: with an empty axis every assertion below is vacuous, and // an empty axis is itself the bug in the other direction. - ASSERT_FALSE(a.targetHeaderSet.empty()); + expect(fatal(!a.targetHeaderSet.empty())); for (auto const& t : a.targetHeaderSet) - EXPECT_EQ(t.find(home.generic_string()), std::string::npos) << t; + expect(eq(t.find(home.generic_string()), std::string::npos)) << t; // Two subos on ONE home are still two keys — the relativization must not // flatten the distinction it exists to preserve. auto tc2 = tc; tc2.base.root = home / "subos" / "musl"; const auto b = ck::build_axes(tc2, m, "-std=c++23", {}, "", store); - EXPECT_NE(a.targetHeaderSet, b.targetHeaderSet); - EXPECT_NE(ck::key_hex(a, pkg()), ck::key_hex(b, pkg())); -} + expect(ne(a.targetHeaderSet, b.targetHeaderSet)); + expect(ne(ck::key_hex(a, pkg()), ck::key_hex(b, pkg()))); +}}; // Position-independent code was ABSENT from this key (issue #519). // @@ -612,26 +611,26 @@ TEST(CacheKey, TheHeaderSetCarriesNoAbsoluteHome) { // consumer can write `dependency_linkage = "shared"`, a hit across that line // hands non-PIC objects to a shared link, and the message names a relocation // in a file nobody edited. -TEST(CacheKey, PicChangesTheKey) { +UTest pic_changes_the_key{"CacheKey/PicChangesTheKey", [] { auto b = axes(); auto p = pkg(); const auto without = ck::key_hex(b, p); b.pic = true; - EXPECT_NE(ck::key_hex(b, p), without); -} + expect(ne(ck::key_hex(b, p), without)); +}}; -TEST(CacheKey, PicIsRecordedInEntryJsonSoAHitCanBeAudited) { +UTest pic_is_recorded_in_entry_json_so_a_hit_can_be_audited{"CacheKey/PicIsRecordedInEntryJsonSoAHitCanBeAudited", [] { auto b = axes(); b.pic = true; auto j = ck::to_json(b, pkg()); - ASSERT_TRUE(j["profile"].contains("pic")); - EXPECT_EQ(j["profile"]["pic"], true); -} + expect(fatal(j["profile"].contains("pic"))); + expect(eq(j["profile"]["pic"], true)); +}}; // The CRT model on the MSVC ABI is whole-build like PIC (#718): switching // `cxx_runtime` must not be served an object compiled against the other CRT. // Off the MSVC ABI the axis is empty, so no other entry's key moves. -TEST(CacheKey, TheMsvcCrtModelChangesTheKeyOnlyOnTheMsvcAbi) { +UTest the_msvc_crt_model_changes_the_key_only_on_the_msvc_abi{"CacheKey/TheMsvcCrtModelChangesTheKeyOnlyOnTheMsvcAbi", [] { auto key_for = [](std::string triple, mcpp::toolchain::CompilerId id, std::string cxxRuntime) { mcpp::toolchain::Toolchain tc; @@ -646,26 +645,26 @@ TEST(CacheKey, TheMsvcCrtModelChangesTheKeyOnlyOnTheMsvcAbi) { for (auto id : {CompilerId::Clang, CompilerId::MSVC}) { auto [crtStatic, keyStatic] = key_for("x86_64-pc-windows-msvc", id, "self-contained"); auto [crtDynamic, keyDynamic] = key_for("x86_64-pc-windows-msvc", id, "toolchain-coupled"); - EXPECT_FALSE(crtStatic.empty()); - EXPECT_NE(crtStatic, crtDynamic); - EXPECT_NE(keyStatic, keyDynamic); + expect(!crtStatic.empty()); + expect(ne(crtStatic, crtDynamic)); + expect(ne(keyStatic, keyDynamic)); } auto [crtA, keyA] = key_for("x86_64-linux-gnu", CompilerId::GCC, "self-contained"); auto [crtB, keyB] = key_for("x86_64-linux-gnu", CompilerId::GCC, "toolchain-coupled"); - EXPECT_TRUE(crtA.empty()); - EXPECT_TRUE(crtB.empty()); -} + expect(crtA.empty()); + expect(crtB.empty()); +}}; -TEST(CacheKey, PicDefaultsOffSoExistingEntriesKeepTheirIdentity) { +UTest pic_defaults_off_so_existing_entries_keep_their_identity{"CacheKey/PicDefaultsOffSoExistingEntriesKeepTheirIdentity", [] { ck::BuildAxes fresh; - EXPECT_FALSE(fresh.pic); -} + expect(!fresh.pic); +}}; // #641 item 2: a C++-layer provider's implementation units are compiled at the // level its manifest states, and the graph's level alone does not name it. The // entry must move when that statement moves, and nothing else's entry may move // because the statement exists. -TEST(CacheKey, ACxxLayerProvidersStatedStandardChangesTheKey) { +UTest a_cxx_layer_providers_stated_standard_changes_the_key{"CacheKey/ACxxLayerProvidersStatedStandardChangesTheKey", [] { const std::filesystem::path store = "/home/u/.mcpp/registry/data/xpkgs"; const auto pkgRoot = store / "llvm-x-libcxx/22.1.8.3/libcxx-22.1.8.3"; auto key_for = [&](std::vector provides, std::string standard) { @@ -683,19 +682,19 @@ TEST(CacheKey, ACxxLayerProvidersStatedStandardChangesTheKey) { const auto none = key_for({}, ""); // A provider that states nothing, and a non-provider that states a level, // hash exactly as a package that says neither. - EXPECT_EQ(key_for(layer, ""), none); - EXPECT_EQ(key_for({}, "c++23"), none); + expect(eq(key_for(layer, ""), none)); + expect(eq(key_for({}, "c++23"), none)); // A stated level on a provider moves the key, and two levels are two keys. - EXPECT_NE(key_for(layer, "c++23"), none); - EXPECT_NE(key_for(layer, "c++23"), key_for(layer, "c++26")); -} + expect(ne(key_for(layer, "c++23"), none)); + expect(ne(key_for(layer, "c++23"), key_for(layer, "c++26"))); +}}; // THE MSVC TOOLSET OF THE CLANG ROW IS IN THE KEY. Before it reached the // command line the driver found the STL by itself and `stdlibVersion` held // clang's version, so a Visual Studio update that changed the STL left the key // unchanged. The toolset directory carries its version, so two toolsets are // two keys. -TEST(CacheKey, TheMsvcToolsetOfTheClangRowChangesTheKey) { +UTest the_msvc_toolset_of_the_clang_row_changes_the_key{"CacheKey/TheMsvcToolsetOfTheClangRowChangesTheKey", [] { mcpp::toolchain::Toolchain tc; tc.compiler = mcpp::toolchain::CompilerId::Clang; tc.version = "20.1.7"; @@ -711,18 +710,16 @@ TEST(CacheKey, TheMsvcToolsetOfTheClangRowChangesTheKey) { bool present = false; for (auto const& t : a.targetHeaderSet) present = present || t.find("14.44.35207") != std::string::npos; - EXPECT_TRUE(present) << "the toolset directory is part of the header set"; + expect(bool(present)) << "the toolset directory is part of the header set"; auto tc2 = tc; tc2.base.msvc.toolsDir = "C:/VS/VC/Tools/MSVC/14.51.36014"; - EXPECT_NE(ck::key_hex(a, pkg()), - ck::key_hex(ck::build_axes(tc2, m, "-std=c++23", {}, ""), pkg())); + expect(ne(ck::key_hex(a, pkg()), ck::key_hex(ck::build_axes(tc2, m, "-std=c++23", {}, ""), pkg()))); auto tc3 = tc; tc3.base.msvc.sdkVersion = "10.0.22621.0"; - EXPECT_NE(ck::key_hex(a, pkg()), - ck::key_hex(ck::build_axes(tc3, m, "-std=c++23", {}, ""), pkg())); -} + expect(ne(ck::key_hex(a, pkg()), ck::key_hex(ck::build_axes(tc3, m, "-std=c++23", {}, ""), pkg()))); +}}; // THE BMI PATH CONVENTION MOVES THE KEY OF A PACKAGE THAT HAS ONE, AND NO // OTHER. A GCC BMI records the paths of the modules it imports as its module @@ -731,13 +728,13 @@ TEST(CacheKey, TheMsvcToolsetOfTheClangRowChangesTheKey) { // is set only for a package that provides a module, so a package of object // files keeps the key it had — the serialization of an unset axis is the // serialization from before the axis existed. -TEST(CacheKey, TheBmiPathConventionMovesOnlyThePackagesThatHaveOne) { +UTest the_bmi_path_convention_moves_only_the_packages_that_have_one{"CacheKey/TheBmiPathConventionMovesOnlyThePackagesThatHaveOne", [] { auto withModules = pkg(); withModules.bmiPathConvention = "$root=bmi-dir"; - EXPECT_NE(ck::key_hex(axes(), pkg()), ck::key_hex(axes(), withModules)); + expect(ne(ck::key_hex(axes(), pkg()), ck::key_hex(axes(), withModules))); const auto j = ck::to_json(axes(), withModules); - ASSERT_TRUE(j["language"].contains("bmi_path_convention")); - EXPECT_EQ(j["language"]["bmi_path_convention"], "$root=bmi-dir"); - EXPECT_FALSE(ck::to_json(axes(), pkg())["language"].contains("bmi_path_convention")); -} + expect(fatal(j["language"].contains("bmi_path_convention"))); + expect(eq(j["language"]["bmi_path_convention"], "$root=bmi-dir")); + expect(!(ck::to_json(axes(), pkg())["language"].contains("bmi_path_convention"))); +}}; diff --git a/tests/unit/test_capacity_jobs.cpp b/tests/unit/test_capacity_jobs.cpp index 391f71f54..268d93a90 100644 --- a/tests/unit/test_capacity_jobs.cpp +++ b/tests/unit/test_capacity_jobs.cpp @@ -1,13 +1,15 @@ +import mcpplibs.mtest; // mcpp.build.capacity::recommended_jobs — the `--jobs auto` formula. // // Asserted against SYNTHETIC capacities, not the machine running the test: a // test that asked the host how many cores it has would assert nothing (it would // just restate the answer) and would produce a different verdict on every CI // runner. -#include import mcpp.build.capacity; +using namespace mcpplibs::mtest; + using mcpp::build::capacity::HostCapacity; using mcpp::build::capacity::recommended_jobs; @@ -17,67 +19,67 @@ constexpr unsigned long long GiB = 1024ull * 1024 * 1024; // A homogeneous machine with ample RAM should use its logical CPUs: SMT siblings // still contribute, just less than a full core. -TEST(RecommendedJobs, HomogeneousAndAmpleMemoryUsesLogicalCores) { +UTest homogeneous_and_ample_memory_uses_logical_cores{"RecommendedJobs/HomogeneousAndAmpleMemoryUsesLogicalCores", [] { HostCapacity cap{.logicalCores = 16, .physicalCores = 8, .heterogeneous = false, .totalBytes = 64 * GiB, .availableBytes = 60 * GiB}; - EXPECT_EQ(recommended_jobs(cap), 16); -} + expect(eq(recommended_jobs(cap), 16)); +}}; // The case that motivated the whole function: 32 "cores" on a 13900K are 8 // P-cores + 16 E-cores, and treating them as 32 equal workers overestimates // usable parallelism by more than 2x. -TEST(RecommendedJobs, HeterogeneousFallsBackToPhysicalCores) { +UTest heterogeneous_falls_back_to_physical_cores{"RecommendedJobs/HeterogeneousFallsBackToPhysicalCores", [] { HostCapacity cap{.logicalCores = 32, .physicalCores = 24, .heterogeneous = true, .totalBytes = 64 * GiB, .availableBytes = 60 * GiB}; - EXPECT_EQ(recommended_jobs(cap), 24); -} + expect(eq(recommended_jobs(cap), 24)); +}}; // The dangerous shape: many cores, little RAM. ninja's default here would be 66 // concurrent compiles against ~0.75 GB each and the machine would swap. -TEST(RecommendedJobs, MemoryBoundMachineIsCappedByRam) { +UTest memory_bound_machine_is_capped_by_ram{"RecommendedJobs/MemoryBoundMachineIsCappedByRam", [] { HostCapacity cap{.logicalCores = 64, .physicalCores = 64, .heterogeneous = false, .totalBytes = 32 * GiB, .availableBytes = 32 * GiB}; // (32 - 2) / 0.75 = 40 → below the 64 CPUs, so memory decides. - EXPECT_EQ(recommended_jobs(cap), 40); - EXPECT_LT(recommended_jobs(cap), cap.logicalCores); -} + expect(eq(recommended_jobs(cap), 40)); + expect(lt(recommended_jobs(cap), cap.logicalCores)); +}}; // Must still make progress rather than refusing to build. -TEST(RecommendedJobs, TinyMemoryStillYieldsOneJob) { +UTest tiny_memory_still_yields_one_job{"RecommendedJobs/TinyMemoryStillYieldsOneJob", [] { HostCapacity cap{.logicalCores = 8, .physicalCores = 8, .heterogeneous = false, .totalBytes = 2 * GiB, .availableBytes = 1 * GiB}; - EXPECT_EQ(recommended_jobs(cap), 1); -} + expect(eq(recommended_jobs(cap), 1)); +}}; -TEST(RecommendedJobs, NeverExceedsTheCeiling) { +UTest never_exceeds_the_ceiling{"RecommendedJobs/NeverExceedsTheCeiling", [] { HostCapacity cap{.logicalCores = 256, .physicalCores = 256, .heterogeneous = false, .totalBytes = 1024 * GiB, .availableBytes = 1000 * GiB}; - EXPECT_EQ(recommended_jobs(cap), 64); - EXPECT_EQ(recommended_jobs(cap, 768ull * 1024 * 1024, 2 * GiB, /*ceiling=*/8), 8); -} + expect(eq(recommended_jobs(cap), 64)); + expect(eq(recommended_jobs(cap, 768ull * 1024 * 1024, 2 * GiB, /*ceiling=*/8), 8)); +}}; -TEST(RecommendedJobs, NeverReturnsZero) { +UTest never_returns_zero{"RecommendedJobs/NeverReturnsZero", [] { HostCapacity cap{.logicalCores = 0, .physicalCores = 0, .heterogeneous = false, .totalBytes = 0, .availableBytes = 0}; - EXPECT_GE(recommended_jobs(cap), 1); -} + expect(ge(recommended_jobs(cap), 1)); +}}; // A project with heavier translation units can say so without patching mcpp. -TEST(RecommendedJobs, PerJobEstimateIsAParameter) { +UTest per_job_estimate_is_a_parameter{"RecommendedJobs/PerJobEstimateIsAParameter", [] { HostCapacity cap{.logicalCores = 32, .physicalCores = 32, .heterogeneous = false, .totalBytes = 32 * GiB, .availableBytes = 32 * GiB}; const int light = recommended_jobs(cap, 256ull * 1024 * 1024); const int heavy = recommended_jobs(cap, 4ull * 1024 * 1024 * 1024); - EXPECT_GT(light, heavy); - EXPECT_LE(heavy, 8); -} + expect(gt(light, heavy)); + expect(le(heavy, 8)); +}}; // The real machine must at least produce something sane — a weak assertion on // purpose, since the value is host-dependent. -TEST(RecommendedJobs, HostQueryIsPlausible) { +UTest host_query_is_plausible{"RecommendedJobs/HostQueryIsPlausible", [] { const auto cap = mcpp::build::capacity::host_capacity(); - EXPECT_GE(cap.logicalCores, 1); - EXPECT_GE(cap.physicalCores, 1); - EXPECT_LE(cap.physicalCores, cap.logicalCores); - EXPECT_GE(recommended_jobs(cap), 1); -} + expect(ge(cap.logicalCores, 1)); + expect(ge(cap.physicalCores, 1)); + expect(le(cap.physicalCores, cap.logicalCores)); + expect(ge(recommended_jobs(cap), 1)); +}}; diff --git a/tests/unit/test_cenv.cpp b/tests/unit/test_cenv.cpp index 3cffcf73d..35ab11b07 100644 --- a/tests/unit/test_cenv.cpp +++ b/tests/unit/test_cenv.cpp @@ -6,12 +6,14 @@ // `mcpp.targetside::resolve` — so the whole mapping table §3.3 publishes can // be asserted here without compiling anything. -#include import std; +import mcpplibs.mtest; import mcpp.targetside; import mcpp.toolchain.cenv; +using namespace mcpplibs::mtest; + namespace ts = mcpp::targetside; namespace cenv = mcpp::toolchain::cenv; @@ -36,14 +38,14 @@ bool has(const std::vector& v, std::string_view tok) { // ── §3.3's own table: the three realisable rows ────────────────────────────── -TEST(CEnv, LinuxPosixArchDefaultIsANoOp) { +UTest linux_posix_arch_default_is_a_no_op{"CEnv/LinuxPosixArchDefaultIsANoOp", [] { auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32); auto r = cenv::realise(d, "linux", "x86_64", false); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_TRUE(r->tokens.empty()); - EXPECT_EQ(r->expectLongBytes, 8); - EXPECT_EQ(r->expectWcharBits, 32); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(r->tokens.empty()); + expect(eq(r->expectLongBytes, 8)); + expect(eq(r->expectWcharBits, 32)); +}}; // NOT a no-op — a correction from the design's original text, caught by the // verification probe on a real build (coordinator report): Apple's clang @@ -53,14 +55,14 @@ TEST(CEnv, LinuxPosixArchDefaultIsANoOp) { // macOS's cost for that fact is one token, `-D__unix__`, same as a // freestanding target's (`FreestandingPosixDefinesUnix` below) and for the // identical reason: neither already has it. -TEST(CEnv, MacosPosixArchDefaultDefinesUnix) { +UTest macos_posix_arch_default_defines_unix{"CEnv/MacosPosixArchDefaultDefinesUnix", [] { auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32); auto r = cenv::realise(d, "macos", "aarch64", false); - ASSERT_TRUE(r.has_value()) << r.error(); - ASSERT_TRUE(has(r->tokens, "-D__unix__")); - ASSERT_TRUE(has(r->expectDefined, "__unix__")); - ASSERT_TRUE(has(r->expectUndefined, "_WIN32")); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(fatal(bool(has(r->tokens, "-D__unix__")))); + expect(fatal(bool(has(r->expectDefined, "__unix__")))); + expect(fatal(bool(has(r->expectUndefined, "_WIN32")))); +}}; // The flagship case: Cygwin-flavoured Windows. // @@ -92,120 +94,120 @@ TEST(CEnv, MacosPosixArchDefaultDefinesUnix) { // against the PUBLISHED 0.13.0 on this engine, which fails on libunwind's // two `static_assert`s exactly as predicted. An ordering between // repositories has no assertion in either of them. -TEST(CEnv, WindowsPosixArchDefaultSubstitutesTheCygwinTriple) { +UTest windows_posix_arch_default_substitutes_the_cygwin_triple{"CEnv/WindowsPosixArchDefaultSubstitutesTheCygwinTriple", [] { auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32); auto r = cenv::realise(d, "windows", "x86_64", false); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_TRUE(has(r->tokens, "--target=x86_64-pc-cygwin")); + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(bool(has(r->tokens, "--target=x86_64-pc-cygwin"))); // The target's identity is not this module's to state: see above. - EXPECT_FALSE(has(r->tokens, "-D__MCPP_TARGET_WINDOWS__=1")); + expect(!has(r->tokens, "-D__MCPP_TARGET_WINDOWS__=1")); // The borrowed name is undefined on the compile line. `-U` rather than a // different triple: the Cygwin identity is what supplies `__unix__` and // suppresses `_WIN32`, which is the point of `presents = "posix"`; only // the NAME was unwanted. - EXPECT_TRUE(has(r->tokens, "-U__CYGWIN__")); - EXPECT_TRUE(has(r->tokens, "-U__CYGWIN32__")); + expect(bool(has(r->tokens, "-U__CYGWIN__"))); + expect(bool(has(r->tokens, "-U__CYGWIN32__"))); // wchar 32 differs from Cygwin's own default (16) — the flag is added. - EXPECT_TRUE(has(r->tokens, "-fno-short-wchar")); - EXPECT_EQ(r->expectLongBytes, 8); - EXPECT_EQ(r->expectWcharBits, 32); - ASSERT_TRUE(has(r->expectDefined, "__unix__")); - ASSERT_TRUE(has(r->expectUndefined, "_WIN32")); + expect(bool(has(r->tokens, "-fno-short-wchar"))); + expect(eq(r->expectLongBytes, 8)); + expect(eq(r->expectWcharBits, 32)); + expect(fatal(bool(has(r->expectDefined, "__unix__")))); + expect(fatal(bool(has(r->expectUndefined, "_WIN32")))); // THE PROBE CHECKS THESE, which is what keeps the tokens from being ones // the compiler could ignore: `cenv_probe::verify` compares the realised // configuration's predefines against these lists and refuses on a // mismatch. A `-U` that did not take effect is a verification failure, // not a silent one --- and silence is the whole risk here, since a `#if` // that finds the name still defined simply compiles the other branch. - ASSERT_TRUE(has(r->expectUndefined, "__CYGWIN__")); - ASSERT_TRUE(has(r->expectUndefined, "__CYGWIN32__")); - EXPECT_FALSE(has(r->expectDefined, "__CYGWIN__")); -} + expect(fatal(bool(has(r->expectUndefined, "__CYGWIN__")))); + expect(fatal(bool(has(r->expectUndefined, "__CYGWIN32__")))); + expect(!has(r->expectDefined, "__CYGWIN__")); +}}; // wchar = 16 on the Cygwin substitution matches Cygwin's own default, so no // `-f[no-]short-wchar` token is needed. -TEST(CEnv, WindowsPosixWchar16NeedsNoWcharFlag) { +UTest windows_posix_wchar16_needs_no_wchar_flag{"CEnv/WindowsPosixWchar16NeedsNoWcharFlag", [] { auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 16); auto r = cenv::realise(d, "windows", "x86_64", false); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_FALSE(has(r->tokens, "-fno-short-wchar")); - EXPECT_FALSE(has(r->tokens, "-fshort-wchar")); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(!has(r->tokens, "-fno-short-wchar")); + expect(!has(r->tokens, "-fshort-wchar")); +}}; // `presents = "windows"` on Windows is the base triple's own identity. -TEST(CEnv, WindowsWindowsArchDefaultIsANoOp) { +UTest windows_windows_arch_default_is_a_no_op{"CEnv/WindowsWindowsArchDefaultIsANoOp", [] { auto d = decl(ts::CAbiPresents::Windows, ts::CAbiDataModel::ArchDefault, 16); auto r = cenv::realise(d, "windows", "x86_64", false); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_TRUE(r->tokens.empty()); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(r->tokens.empty()); +}}; // ── `builtins = "iso"` — the Apple case is the only one this survey found ──── -TEST(CEnv, BuiltinsIsoOnMacosDisablesMemsetPattern16) { +UTest builtins_iso_on_macos_disables_memset_pattern16{"CEnv/BuiltinsIsoOnMacosDisablesMemsetPattern16", [] { auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32, ts::CAbiBuiltins::Iso); auto r = cenv::realise(d, "macos", "x86_64", false); - ASSERT_TRUE(r.has_value()) << r.error(); + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); // `-fno-builtin`, NOT the per-function spelling. The narrower form was // emitted here and measured to do nothing: clang checks `-fno-builtin-X` // against its builtin table, `memset_pattern16` is an LLVM TLI libfunc, // and the call is produced by a pass that consults TLI. clang accepts an // unknown name in that family in silence, so the flag looked right and // was not. - EXPECT_TRUE(has(r->builtinsTokens, "-fno-builtin")); - EXPECT_FALSE(has(r->builtinsTokens, "-fno-builtin-memset_pattern16")); -} + expect(bool(has(r->builtinsTokens, "-fno-builtin"))); + expect(!has(r->builtinsTokens, "-fno-builtin-memset_pattern16")); +}}; -TEST(CEnv, BuiltinsIsoOnLinuxAddsNothingMeasurable) { +UTest builtins_iso_on_linux_adds_nothing_measurable{"CEnv/BuiltinsIsoOnLinuxAddsNothingMeasurable", [] { auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32, ts::CAbiBuiltins::Iso); auto r = cenv::realise(d, "linux", "x86_64", false); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_TRUE(r->builtinsTokens.empty()); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(r->builtinsTokens.empty()); +}}; -TEST(CEnv, BuiltinsPlatformAddsNothingOnAnyTarget) { +UTest builtins_platform_adds_nothing_on_any_target{"CEnv/BuiltinsPlatformAddsNothingOnAnyTarget", [] { auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32, ts::CAbiBuiltins::Platform); auto r = cenv::realise(d, "macos", "x86_64", false); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_TRUE(r->builtinsTokens.empty()); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(r->builtinsTokens.empty()); +}}; // ── refusals: everything outside the table §3.3 publishes ─────────────────── -TEST(CEnv, WindowsPosixOnANonX86_64ArchIsRefused) { +UTest windows_posix_on_a_non_x86_64_arch_is_refused{"CEnv/WindowsPosixOnANonX86_64ArchIsRefused", [] { auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32); auto r = cenv::realise(d, "windows", "aarch64", false); - ASSERT_FALSE(r.has_value()); - EXPECT_NE(r.error().find("x86_64"), std::string::npos) << r.error(); -} + expect(fatal(!r.has_value())); + if (auto&& failed = expect(ne(r.error().find("x86_64"), std::string::npos)); !failed) failed << r.error(); +}}; -TEST(CEnv, LinuxWindowsPresentsIsRefused) { +UTest linux_windows_presents_is_refused{"CEnv/LinuxWindowsPresentsIsRefused", [] { auto d = decl(ts::CAbiPresents::Windows, ts::CAbiDataModel::ArchDefault, 32); auto r = cenv::realise(d, "linux", "x86_64", false); - ASSERT_FALSE(r.has_value()); - EXPECT_NE(r.error().find("no known way"), std::string::npos) << r.error(); -} + expect(fatal(!r.has_value())); + if (auto&& failed = expect(ne(r.error().find("no known way"), std::string::npos)); !failed) failed << r.error(); +}}; -TEST(CEnv, WindowsPresentsNoneIsRefused) { +UTest windows_presents_none_is_refused{"CEnv/WindowsPresentsNoneIsRefused", [] { auto d = decl(ts::CAbiPresents::None, ts::CAbiDataModel::ArchDefault, 32); auto r = cenv::realise(d, "windows", "x86_64", false); - ASSERT_FALSE(r.has_value()); -} + expect(fatal(!r.has_value())); +}}; -TEST(CEnv, LlP64RequestedOnLinuxIsRefused) { +UTest ll_p64_requested_on_linux_is_refused{"CEnv/LlP64RequestedOnLinuxIsRefused", [] { auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::Llp64, 32); auto r = cenv::realise(d, "linux", "x86_64", false); - ASSERT_FALSE(r.has_value()); - EXPECT_NE(r.error().find("data-model"), std::string::npos) << r.error(); -} + expect(fatal(!r.has_value())); + if (auto&& failed = expect(ne(r.error().find("data-model"), std::string::npos)); !failed) failed << r.error(); +}}; -TEST(CEnv, FreestandingAcceptsNone) { +UTest freestanding_accepts_none{"CEnv/FreestandingAcceptsNone", [] { auto ok = decl(ts::CAbiPresents::None, ts::CAbiDataModel::ArchDefault, 32); auto r1 = cenv::realise(ok, "none", "riscv64", true); - ASSERT_TRUE(r1.has_value()) << r1.error(); + if (auto&& failed = expect(fatal(r1.has_value())); !failed) failed << r1.error(); // The wchar branch ALWAYS emits on a freestanding target now (wave // 2026-09-18, openkal-llvm-runtime#24, Windows-host × riscv64-none-elf // measurement): the toolchain's host-contaminated default is not @@ -214,15 +216,15 @@ TEST(CEnv, FreestandingAcceptsNone) { // macros stay absent (the `none` presents value's whole point), // but the wchar realisation still applies — and so does the // expectation, which the probe then measures. - EXPECT_TRUE(has(r1->tokens, "-fno-short-wchar")); - EXPECT_TRUE(r1->expectUndefined.empty()) + expect(bool(has(r1->tokens, "-fno-short-wchar"))); + expect(r1->expectUndefined.empty()) << "presents = none declares NO environment-identity macros, " "neither defined nor undefined"; - EXPECT_TRUE(r1->expectDefined.empty()); + expect(r1->expectDefined.empty()); // Data-model is not checked on freestanding (no C library runtime to // present it), so `expectLongBytes` stays at 0. - EXPECT_EQ(r1->expectLongBytes, 0); -} + expect(eq(r1->expectLongBytes, 0)); +}}; // A freestanding target ALSO realises `presents = "posix"` (design §3.3, // coordinator revision — this used to be an unconditional refusal, which is @@ -230,14 +232,14 @@ TEST(CEnv, FreestandingAcceptsNone) { // before reaching any other target, for a fact this engine can in fact // deliver). Bare metal starts with neither `_WIN32` nor `__unix__` defined, // same as macOS's default triple, so it costs the identical one token. -TEST(CEnv, FreestandingPosixDefinesUnix) { +UTest freestanding_posix_defines_unix{"CEnv/FreestandingPosixDefinesUnix", [] { auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32); auto r = cenv::realise(d, "none", "riscv64", true); - ASSERT_TRUE(r.has_value()) << r.error(); - ASSERT_TRUE(has(r->tokens, "-D__unix__")); - ASSERT_TRUE(has(r->expectDefined, "__unix__")); - ASSERT_TRUE(has(r->expectUndefined, "_WIN32")); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(fatal(bool(has(r->tokens, "-D__unix__")))); + expect(fatal(bool(has(r->expectDefined, "__unix__")))); + expect(fatal(bool(has(r->expectUndefined, "_WIN32")))); +}}; // A freestanding target with `wchar = 32` USED TO skip the // `-fno-short-wchar` token on the reasoning that the toolchain default was @@ -251,44 +253,44 @@ TEST(CEnv, FreestandingPosixDefinesUnix) { // the engine actually produced (32 bits, with the flag), not the host's // leak. Pinned here so a future "freestanding skips the flag" optimisation // cannot return without a regression test. -TEST(CEnv, FreestandingWchar32AlwaysEmitsNoShortWchar) { +UTest freestanding_wchar32_always_emits_no_short_wchar{"CEnv/FreestandingWchar32AlwaysEmitsNoShortWchar", [] { auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32); auto r = cenv::realise(d, "none", "riscv64", true); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_TRUE(has(r->tokens, "-fno-short-wchar")) + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(bool(has(r->tokens, "-fno-short-wchar"))) << "freestanding host leakage on Windows would compile a 16-bit " "wchar_t otherwise; the wave's measurement is the source for " "this assertion."; - EXPECT_EQ(r->expectWcharBits, 32); -} + expect(eq(r->expectWcharBits, 32)); +}}; // The matching case for `wchar = 16`: `-fshort-wchar` always emitted on // freestanding when the declaration asks for 16, so a Linux/macOS host // (which defaults to 32 bits on a freestanding target) is brought down to // the declaration. Mirrors the wave's measurement in the other direction. -TEST(CEnv, FreestandingWchar16AlwaysEmitsShortWchar) { +UTest freestanding_wchar16_always_emits_short_wchar{"CEnv/FreestandingWchar16AlwaysEmitsShortWchar", [] { auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 16); auto r = cenv::realise(d, "none", "riscv64", true); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_TRUE(has(r->tokens, "-fshort-wchar")); - EXPECT_EQ(r->expectWcharBits, 16); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(bool(has(r->tokens, "-fshort-wchar"))); + expect(eq(r->expectWcharBits, 16)); +}}; // `windows` still has no realisation on a freestanding target — there is no // operating system under it for that identity to belong to. Unaffected by // the `posix` fix above; pinned so it stays that way. -TEST(CEnv, FreestandingWindowsIsStillRefused) { +UTest freestanding_windows_is_still_refused{"CEnv/FreestandingWindowsIsStillRefused", [] { auto d = decl(ts::CAbiPresents::Windows, ts::CAbiDataModel::ArchDefault, 16); auto r = cenv::realise(d, "none", "riscv64", true); - ASSERT_FALSE(r.has_value()); -} + expect(fatal(!r.has_value())); +}}; // A 32-bit architecture's own native data model is ILP32 — the Cygwin // mechanism is x86_64-only, so `arch-default` there needs no substitution and // stays a no-op regardless. -TEST(CEnv, Ilp32ArchDefaultOnA32BitArchIsANoOp) { +UTest ilp32_arch_default_on_a32_bit_arch_is_a_no_op{"CEnv/Ilp32ArchDefaultOnA32BitArchIsANoOp", [] { auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32); auto r = cenv::realise(d, "linux", "arm", false); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_EQ(r->expectLongBytes, 4); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(eq(r->expectLongBytes, 4)); +}}; diff --git a/tests/unit/test_cenv_probe.cpp b/tests/unit/test_cenv_probe.cpp index ab6e40147..00005019a 100644 --- a/tests/unit/test_cenv_probe.cpp +++ b/tests/unit/test_cenv_probe.cpp @@ -33,12 +33,14 @@ // parameter exists for; using it here is not a workaround, it is the // intended way to keep two callers' cached dumps apart. -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.cenv_probe; import mcpp.run; +using namespace mcpplibs::mtest; + namespace cp = mcpp::toolchain::cenv_probe; namespace { @@ -153,77 +155,76 @@ struct TmpCache { } // namespace -TEST(CenvProbe, AMatchingDeclarationProducesNoMismatches) { - if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; +UTest a_matching_declaration_produces_no_mismatches{"CenvProbe/AMatchingDeclarationProducesNoMismatches", [] { + if (cxx().empty()) skip("no C++ compiler found to probe"); TmpCache cache; auto r = cp::verify(cxx(), {}, kHostWcharBits, kHostLongBytes, {}, {}, cache.dir); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_TRUE(r->ran) << "a fresh cache directory must actually probe"; - EXPECT_TRUE(r->mismatches.empty()); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(bool(r->ran)) << "a fresh cache directory must actually probe"; + expect(r->mismatches.empty()); +}}; // THE CASE THE COORDINATOR ASKED TO SEE: a declared word width the compiler // disagrees with. This is exactly what a C library's [c-abi] block getting // `data-model` wrong would produce once it reached the probe, independent // of whether `cenv::realise`'s own mapping table would ever have accepted // that request in the first place. -TEST(CenvProbe, ADeclaredLongWidthTheCompilerDisagreesWithIsAMismatch) { - if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; +UTest a_declared_long_width_the_compiler_disagrees_with_is_a_mismatch{"CenvProbe/ADeclaredLongWidthTheCompilerDisagreesWithIsAMismatch", [] { + if (cxx().empty()) skip("no C++ compiler found to probe"); TmpCache cache; const int wrongLongBytes = kHostLongBytes == 8 ? 4 : 8; auto r = cp::verify(cxx(), {}, 0, wrongLongBytes, {}, {}, cache.dir); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_TRUE(r->ran); - ASSERT_EQ(r->mismatches.size(), 1u); - EXPECT_EQ(r->mismatches[0].fact, "sizeof(long)"); - EXPECT_EQ(r->mismatches[0].declared, std::to_string(wrongLongBytes)); - EXPECT_EQ(r->mismatches[0].measured, std::to_string(kHostLongBytes)); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(bool(r->ran)); + expect(fatal(eq(r->mismatches.size(), 1u))); + expect(eq(r->mismatches[0].fact, "sizeof(long)")); + expect(eq(r->mismatches[0].declared, std::to_string(wrongLongBytes))); + expect(eq(r->mismatches[0].measured, std::to_string(kHostLongBytes))); +}}; -TEST(CenvProbe, ADeclaredWcharWidthTheCompilerDisagreesWithIsAMismatch) { - if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; +UTest a_declared_wchar_width_the_compiler_disagrees_with_is_a_mismatch{"CenvProbe/ADeclaredWcharWidthTheCompilerDisagreesWithIsAMismatch", [] { + if (cxx().empty()) skip("no C++ compiler found to probe"); TmpCache cache; const int wrongWcharBits = kHostWcharBits == 32 ? 16 : 32; auto r = cp::verify(cxx(), {}, wrongWcharBits, 0, {}, {}, cache.dir); - ASSERT_TRUE(r.has_value()) << r.error(); - ASSERT_EQ(r->mismatches.size(), 1u); - EXPECT_EQ(r->mismatches[0].fact, "__SIZEOF_WCHAR_T__ (bits)"); - EXPECT_EQ(r->mismatches[0].declared, std::to_string(wrongWcharBits)); - EXPECT_EQ(r->mismatches[0].measured, std::to_string(kHostWcharBits)); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(fatal(eq(r->mismatches.size(), 1u))); + expect(eq(r->mismatches[0].fact, "__SIZEOF_WCHAR_T__ (bits)")); + expect(eq(r->mismatches[0].declared, std::to_string(wrongWcharBits))); + expect(eq(r->mismatches[0].measured, std::to_string(kHostWcharBits))); +}}; // `expectDefined` names a macro the declaration says must be there and // isn't — the shape a `presents = "posix"` declaration takes when the // compiler did not actually end up defining `__unix__`. -TEST(CenvProbe, AMacroDeclaredDefinedButAbsentIsAMismatch) { - if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; +UTest a_macro_declared_defined_but_absent_is_a_mismatch{"CenvProbe/AMacroDeclaredDefinedButAbsentIsAMismatch", [] { + if (cxx().empty()) skip("no C++ compiler found to probe"); TmpCache cache; auto r = cp::verify(cxx(), {}, 0, 0, {"__MCPP_TEST_MACRO_THAT_DOES_NOT_EXIST__"}, {}, cache.dir); - ASSERT_TRUE(r.has_value()) << r.error(); - ASSERT_EQ(r->mismatches.size(), 1u); - EXPECT_EQ(r->mismatches[0].fact, - "__MCPP_TEST_MACRO_THAT_DOES_NOT_EXIST__"); - EXPECT_EQ(r->mismatches[0].declared, "defined"); - EXPECT_EQ(r->mismatches[0].measured, "undefined"); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(fatal(eq(r->mismatches.size(), 1u))); + expect(eq(r->mismatches[0].fact, "__MCPP_TEST_MACRO_THAT_DOES_NOT_EXIST__")); + expect(eq(r->mismatches[0].declared, "defined")); + expect(eq(r->mismatches[0].measured, "undefined")); +}}; // The reverse: a macro the declaration says must be ABSENT but the compiler // defines anyway — the shape `presents = "posix"` on a target whose // compiler still predefines `_WIN32` would take. `__cplusplus` is always // defined by a working C++ compiler, so this needs no special setup. -TEST(CenvProbe, AMacroDeclaredUndefinedButPresentIsAMismatch) { - if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; +UTest a_macro_declared_undefined_but_present_is_a_mismatch{"CenvProbe/AMacroDeclaredUndefinedButPresentIsAMismatch", [] { + if (cxx().empty()) skip("no C++ compiler found to probe"); TmpCache cache; auto r = cp::verify(cxx(), {}, 0, 0, {}, {"__cplusplus"}, cache.dir); - ASSERT_TRUE(r.has_value()) << r.error(); - ASSERT_EQ(r->mismatches.size(), 1u); - EXPECT_EQ(r->mismatches[0].fact, "__cplusplus"); - EXPECT_EQ(r->mismatches[0].declared, "undefined"); - EXPECT_EQ(r->mismatches[0].measured, "defined"); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(fatal(eq(r->mismatches.size(), 1u))); + expect(eq(r->mismatches[0].fact, "__cplusplus")); + expect(eq(r->mismatches[0].declared, "undefined")); + expect(eq(r->mismatches[0].measured, "defined")); +}}; // THE EXACT TOKEN `cenv::realise` NOW PRODUCES FOR macOS AND A FREESTANDING // TARGET (design §3.3, coordinator revision) — `-D__unix__` — PASSED TO A @@ -240,48 +241,47 @@ TEST(CenvProbe, AMacroDeclaredUndefinedButPresentIsAMismatch) { // harmlessly redundant there rather than wrong, and the assertion is exactly // the same declared/measured pair regardless) still ends up matching what // was declared. -TEST(CenvProbe, TheDUnixTokenCenvRealiseProducesForMacosAndFreestandingSatisfiesItsOwnExpectation) { - if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; +UTest the_d_unix_token_cenv_realise_produces_for_macos_and_freestanding_satisfies_its_own_expectation{"CenvProbe/TheDUnixTokenCenvRealiseProducesForMacosAndFreestandingSatisfiesItsOwnExpectation", [] { + if (cxx().empty()) skip("no C++ compiler found to probe"); TmpCache cache; auto r = cp::verify(cxx(), {"-D__unix__"}, 0, 0, {"__unix__"}, {"_WIN32"}, cache.dir); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_TRUE(r->mismatches.empty()) - << (r->mismatches.empty() ? "" : r->mismatches[0].fact); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + if (auto&& failed = expect(r->mismatches.empty()); !failed) failed << (r->mismatches.empty() ? "" : r->mismatches[0].fact); +}}; // CACHED PER CONFIGURATION (module header) — a second call with the // identical compiler + argv must not recompile. Distinguished from the // first call via `ran`, exactly as `Result::ran`'s own doc comment says: a // cache hit still answers the mismatch question, it just does no work to. -TEST(CenvProbe, TheSecondCallForTheIdenticalConfigurationIsACacheHit) { - if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; +UTest the_second_call_for_the_identical_configuration_is_a_cache_hit{"CenvProbe/TheSecondCallForTheIdenticalConfigurationIsACacheHit", [] { + if (cxx().empty()) skip("no C++ compiler found to probe"); TmpCache cache; auto first = cp::verify(cxx(), {}, kHostWcharBits, kHostLongBytes, {}, {}, cache.dir); - ASSERT_TRUE(first.has_value()) << first.error(); - EXPECT_TRUE(first->ran); + if (auto&& failed = expect(fatal(first.has_value())); !failed) failed << first.error(); + expect(bool(first->ran)); auto second = cp::verify(cxx(), {}, kHostWcharBits, kHostLongBytes, {}, {}, cache.dir); - ASSERT_TRUE(second.has_value()) << second.error(); - EXPECT_FALSE(second->ran) << "identical compiler + argv must hit the cache"; - EXPECT_TRUE(second->mismatches.empty()); -} + if (auto&& failed = expect(fatal(second.has_value())); !failed) failed << second.error(); + expect(!second->ran) << "identical compiler + argv must hit the cache"; + expect(second->mismatches.empty()); +}}; // TWO different argv strings must NOT share a cache slot — the whole point // of hashing argv into the key. A wrong hit here would mean this build's // probe silently answered a DIFFERENT configuration's question. -TEST(CenvProbe, DifferentArgvDoesNotShareACacheSlot) { - if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; +UTest different_argv_does_not_share_a_cache_slot{"CenvProbe/DifferentArgvDoesNotShareACacheSlot", [] { + if (cxx().empty()) skip("no C++ compiler found to probe"); TmpCache cache; auto a = cp::verify(cxx(), {"-DMCPP_PROBE_TEST_A=1"}, 0, 0, {}, {}, cache.dir); auto b = cp::verify(cxx(), {"-DMCPP_PROBE_TEST_B=1"}, 0, 0, {}, {}, cache.dir); - ASSERT_TRUE(a.has_value()) << a.error(); - ASSERT_TRUE(b.has_value()) << b.error(); - EXPECT_TRUE(a->ran); - EXPECT_TRUE(b->ran) << "a different argv must not read A's cache entry"; -} + if (auto&& failed = expect(fatal(a.has_value())); !failed) failed << a.error(); + if (auto&& failed = expect(fatal(b.has_value())); !failed) failed << b.error(); + expect(bool(a->ran)); + expect(bool(b->ran)) << "a different argv must not read A's cache entry"; +}}; // ── assemble_argv — the invariant, reachable without a cross toolchain ──── // @@ -291,13 +291,13 @@ TEST(CenvProbe, DifferentArgvDoesNotShareACacheSlot) { // cannot live at the call site; it lives in the assembly, and these tests // reach it with no compiler at all. -TEST(CenvProbeArgv, AFreestandingTargetWithoutASelectionIsRefused) { +UTest a_freestanding_target_without_a_selection_is_refused{"CenvProbeArgv/AFreestandingTargetWithoutASelectionIsRefused", [] { // Exactly the 2026.9.18.3 shape: `crossTargetFlag` empty (it is set for // hosted targets only), no freestanding flags supplied, and `realise`'s // freestanding output, which carries no `--target`. auto r = cp::assemble_argv("", {}, {"-D__unix__", "-fno-short-wchar"}, {}, /*freestanding=*/true, "riscv64-none-elf"); - ASSERT_FALSE(r.has_value()) + expect(fatal(!r.has_value())) << "an argv with no target selection must be refused for a " "freestanding target; it measures the build host"; // THE CLAUSE, NOT ONLY THE TRIPLE. Two other refusals this wave added were @@ -308,56 +308,53 @@ TEST(CenvProbeArgv, AFreestandingTargetWithoutASelectionIsRefused) { // delimiters, so it has no such failure mode -- the assertion is widened // anyway, because that is a property of today's wording rather than of // the check. - EXPECT_NE(r.error().find("would have run with no target selection"), - std::string::npos) << r.error(); - EXPECT_NE(r.error().find("riscv64-none-elf"), std::string::npos) - << "the refusal must name the target it was for: " << r.error(); -} + expect(ne(r.error().find("would have run with no target selection"), std::string::npos)) << r.error(); + if (auto&& failed = expect(ne(r.error().find("riscv64-none-elf"), std::string::npos)); !failed) failed << "the refusal must name the target it was for: " << r.error(); +}}; -TEST(CenvProbeArgv, AFreestandingTargetWithItsCompilePrefixIsAccepted) { +UTest a_freestanding_target_with_its_compile_prefix_is_accepted{"CenvProbeArgv/AFreestandingTargetWithItsCompilePrefixIsAccepted", [] { std::vector fs{"--target=riscv64-none-elf", "-march=rv64gc", "-mabi=lp64d", "-ffreestanding"}; auto r = cp::assemble_argv("", fs, {"-D__unix__", "-fno-short-wchar"}, {}, true, "riscv64-none-elf"); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_NE(std::find(r->begin(), r->end(), "--target=riscv64-none-elf"), - r->end()); + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(ne(std::find(r->begin(), r->end(), "--target=riscv64-none-elf"), r->end())); // The ISA flags travel with the target: they change what the compiler // predefines (`__riscv_xlen`, the float ABI), so an argv that took the // triple and left them behind would measure a different machine again. - EXPECT_NE(std::find(r->begin(), r->end(), "-mabi=lp64d"), r->end()); -} + expect(ne(std::find(r->begin(), r->end(), "-mabi=lp64d"), r->end())); +}}; -TEST(CenvProbeArgv, ANativeHostedBuildNeedsNoSelection) { +UTest a_native_hosted_build_needs_no_selection{"CenvProbeArgv/ANativeHostedBuildNeedsNoSelection", [] { // The host IS the target; the absence is the decision rather than its // omission, and the probe must not be refused for it. openkal-musl on // Linux/x86_64 is this case: `realise` produces no tokens at all. auto r = cp::assemble_argv("", {}, {}, {}, false, "x86_64-linux-gnu"); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_TRUE(r->empty()); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(r->empty()); +}}; -TEST(CenvProbeArgv, AHostedCrossTargetCarriesItsCrossFlag) { +UTest a_hosted_cross_target_carries_its_cross_flag{"CenvProbeArgv/AHostedCrossTargetCarriesItsCrossFlag", [] { auto r = cp::assemble_argv("--target=x86_64-w64-windows-gnu", {}, {"--target=x86_64-pc-cygwin", "-fno-short-wchar"}, {}, false, "x86_64-windows-gnu"); - ASSERT_TRUE(r.has_value()) << r.error(); + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); // Both are present and in this order: clang takes the LAST `--target`, // which is how the Cygwin-flavoured substitution overrides the base // triple without the producer of the base one knowing this module exists. - ASSERT_GE(r->size(), 2u); - EXPECT_EQ((*r)[0], "--target=x86_64-w64-windows-gnu"); - EXPECT_EQ((*r)[1], "--target=x86_64-pc-cygwin"); -} + expect(fatal(ge(r->size(), 2u))); + expect(eq((*r)[0], "--target=x86_64-w64-windows-gnu")); + expect(eq((*r)[1], "--target=x86_64-pc-cygwin")); +}}; -TEST(CenvProbeArgv, BuiltinsTokensAreCarriedLast) { +UTest builtins_tokens_are_carried_last{"CenvProbeArgv/BuiltinsTokensAreCarriedLast", [] { auto r = cp::assemble_argv("", {}, {"-D__unix__"}, {"-fno-builtin"}, false, "x86_64-apple-macos"); - ASSERT_TRUE(r.has_value()) << r.error(); - ASSERT_EQ(r->size(), 2u); - EXPECT_EQ((*r)[1], "-fno-builtin"); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(fatal(eq(r->size(), 2u))); + expect(eq((*r)[1], "-fno-builtin")); +}}; // ── The argv must select the target (mcpp#674 review, 2026-09-20) ────────── // @@ -380,9 +377,9 @@ namespace { bool answers_for_riscv() { return !clangxx().empty(); } } // namespace -TEST(CenvProbe, AnArgvWithATargetMeasuresThatTargetAndNotTheHost) { - if (clangxx().empty()) GTEST_SKIP() << "no clang++ on PATH"; - if (!answers_for_riscv()) GTEST_SKIP() << "this clang does not answer for riscv64-none-elf"; +UTest an_argv_with_a_target_measures_that_target_and_not_the_host{"CenvProbe/AnArgvWithATargetMeasuresThatTargetAndNotTheHost", [] { + if (clangxx().empty()) skip("no clang++ on PATH"); + if (!answers_for_riscv()) skip("this clang does not answer for riscv64-none-elf"); TmpCache cache; // The freestanding probe's own shape, with the target selection the // caller (`mcpp.build.prepare`) now supplies. `__linux__` must be absent @@ -395,15 +392,15 @@ TEST(CenvProbe, AnArgvWithATargetMeasuresThatTargetAndNotTheHost) { std::vector defined{"__unix__", "__riscv"}; std::vector undefined{"__linux__", "_WIN32"}; auto r = cp::verify(clangxx(), argv, 32, 0, defined, undefined, cache.dir); - ASSERT_TRUE(r.has_value()) << r.error(); + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); for (auto const& mm : r->mismatches) - ADD_FAILURE() << mm.fact << ": declared " << mm.declared + expect(false) << mm.fact << ": declared " << mm.declared << ", measured " << mm.measured; -} +}}; -TEST(CenvProbe, TheSameArgvWithoutATargetMeasuresTheHost) { - if (clangxx().empty()) GTEST_SKIP() << "no clang++ on PATH"; - if (!answers_for_riscv()) GTEST_SKIP() << "this clang does not answer for riscv64-none-elf"; +UTest the_same_argv_without_a_target_measures_the_host{"CenvProbe/TheSameArgvWithoutATargetMeasuresTheHost", [] { + if (clangxx().empty()) skip("no clang++ on PATH"); + if (!answers_for_riscv()) skip("this clang does not answer for riscv64-none-elf"); TmpCache cache; // The mirror of the test above, and the one that makes it mean // something: with the target removed, the expectation that held for the @@ -411,9 +408,9 @@ TEST(CenvProbe, TheSameArgvWithoutATargetMeasuresTheHost) { // identically against a probe that ignores its argv. std::vector argv{"-ffreestanding", "-D__unix__", "-fno-short-wchar"}; auto r = cp::verify(clangxx(), argv, 0, 0, {"__riscv"}, {}, cache.dir); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_FALSE(r->mismatches.empty()) + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(!(r->mismatches.empty())) << "a probe with no target selection must not report the target's " "own predefines; if this passes, the argv is not reaching the " "compiler and the test above proves nothing"; -} +}}; diff --git a/tests/unit/test_cfg_accelerator.cpp b/tests/unit/test_cfg_accelerator.cpp index 953ebda48..891417eb6 100644 --- a/tests/unit/test_cfg_accelerator.cpp +++ b/tests/unit/test_cfg_accelerator.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.prepare_inputs; +using namespace mcpplibs::mtest; + namespace cfgpred = mcpp::build::cfgpred; namespace { @@ -34,34 +36,34 @@ bool m(std::string_view predicate, const cfgpred::Ctx& c) { // a = "rocm")` would then be unsatisfiable rather than "both are enabled". // Membership everywhere keeps `any`/`all`/`not` pure boolean combinators. -TEST(CfgAccelerator, BareKeyIsMembership) { +UTest bare_key_is_membership{"CfgAccelerator/BareKeyIsMembership", [] { auto cuda = resolved({"cuda"}); - EXPECT_TRUE (m(R"(cfg(accelerator = "cuda"))", cuda)); - EXPECT_FALSE(m(R"(cfg(accelerator = "rocm"))", cuda)); -} + expect(bool(m(R"(cfg(accelerator = "cuda"))", cuda))); + expect(!(m(R"(cfg(accelerator = "rocm"))", cuda))); +}}; -TEST(CfgAccelerator, MultipleBackendsAreAllMembers) { +UTest multiple_backends_are_all_members{"CfgAccelerator/MultipleBackendsAreAllMembers", [] { auto both = resolved({"cuda", "rocm"}); - EXPECT_TRUE(m(R"(cfg(accelerator = "cuda"))", both)); - EXPECT_TRUE(m(R"(cfg(accelerator = "rocm"))", both)); -} + expect(bool(m(R"(cfg(accelerator = "cuda"))", both))); + expect(bool(m(R"(cfg(accelerator = "rocm"))", both))); +}}; -TEST(CfgAccelerator, CombinatorsStayPure) { +UTest combinators_stay_pure{"CfgAccelerator/CombinatorsStayPure", [] { auto both = resolved({"cuda", "rocm"}); auto cuda = resolved({"cuda"}); - EXPECT_TRUE (m(R"(cfg(any(accelerator = "cuda", accelerator = "rocm")))", cuda)); - EXPECT_TRUE (m(R"(cfg(all(accelerator = "cuda", accelerator = "rocm")))", both)); - EXPECT_FALSE(m(R"(cfg(all(accelerator = "cuda", accelerator = "rocm")))", cuda)); - EXPECT_TRUE (m(R"(cfg(not(accelerator = "rocm")))", cuda)); -} + expect(bool(m(R"(cfg(any(accelerator = "cuda", accelerator = "rocm")))", cuda))); + expect(bool(m(R"(cfg(all(accelerator = "cuda", accelerator = "rocm")))", both))); + expect(!(m(R"(cfg(all(accelerator = "cuda", accelerator = "rocm")))", cuda))); + expect(bool(m(R"(cfg(not(accelerator = "rocm")))", cuda))); +}}; -TEST(CfgAccelerator, ComposesWithTripleKeys) { +UTest composes_with_triple_keys{"CfgAccelerator/ComposesWithTripleKeys", [] { auto cuda = resolved({"cuda"}); - EXPECT_TRUE (m(R"(cfg(all(linux, accelerator = "cuda")))", cuda)); - EXPECT_FALSE(m(R"(cfg(all(windows, accelerator = "cuda")))", cuda)); -} + expect(bool(m(R"(cfg(all(linux, accelerator = "cuda")))", cuda))); + expect(!(m(R"(cfg(all(windows, accelerator = "cuda")))", cuda))); +}}; -TEST(CfgAccelerator, AnsweredBeforeResolutionUnlikeTheOtherLayers) { +UTest answered_before_resolution_unlike_the_other_layers{"CfgAccelerator/AnsweredBeforeResolutionUnlikeTheOtherLayers", [] { // `accelerator` is answerable in the FIRST merge pass, and the difference // from the other five layer keys is the schedule, not the subject. Its // value is an input to the build -- `--accel`, or `[build] accel` -- read @@ -73,43 +75,43 @@ TEST(CfgAccelerator, AnsweredBeforeResolutionUnlikeTheOtherLayers) { // not be, and a CPU-only build of a project with a device island // downloaded the whole vendor toolkit. auto early = cfgpred::context_for("x86_64-linux-gnu"); - ASSERT_FALSE(early.layersKnown); + expect(fatal(!early.layersKnown)); early.accelerators = {"cuda"}; - EXPECT_TRUE (m(R"(cfg(accelerator = "cuda"))", early)); - EXPECT_FALSE(m(R"(cfg(accelerator = "vulkan"))", early)); + expect(bool(m(R"(cfg(accelerator = "cuda"))", early))); + expect(!(m(R"(cfg(accelerator = "vulkan"))", early))); // A resolved layer key in the same position still answers false, and must: // the second pass owns it and would otherwise contribute the same inputs // twice through `append()`. - EXPECT_FALSE(m(R"(cfg(c-abi = "glibc"))", early)); + expect(!(m(R"(cfg(c-abi = "glibc"))", early))); // No accel named: false for any backend, and `none` is how a section says // so without enumerating the backends it is not. auto nothing = cfgpred::context_for("x86_64-linux-gnu"); - EXPECT_FALSE(m(R"(cfg(accelerator = "cuda"))", nothing)); - EXPECT_TRUE (m(R"(cfg(accelerator = "none"))", nothing)); -} + expect(!(m(R"(cfg(accelerator = "cuda"))", nothing))); + expect(bool(m(R"(cfg(accelerator = "none"))", nothing))); +}}; -TEST(CfgAccelerator, IsInTheVocabularyButNotAResolvedLayer) { +UTest is_in_the_vocabulary_but_not_a_resolved_layer{"CfgAccelerator/IsInTheVocabularyButNotAResolvedLayer", [] { // Three separate questions, and only the middle one changed. It is a known // key (so a misspelling is still reported); it is not a triple key; and it // does NOT send its section to the pass that runs after resolution. - EXPECT_TRUE (cfgpred::unknown_tokens(R"(cfg(accelerator = "cuda"))").empty()); - EXPECT_FALSE(cfgpred::uses_layer(R"(cfg(accelerator = "cuda"))")); - EXPECT_FALSE(cfgpred::uses_layer(R"(cfg(arch = "x86_64"))")); - EXPECT_TRUE (cfgpred::uses_layer(R"(cfg(c-abi = "musl"))")); + expect(cfgpred::unknown_tokens(R"(cfg(accelerator = "cuda"))").empty()); + expect(!(cfgpred::uses_layer(R"(cfg(accelerator = "cuda"))"))); + expect(!(cfgpred::uses_layer(R"(cfg(arch = "x86_64"))"))); + expect(bool(cfgpred::uses_layer(R"(cfg(c-abi = "musl"))"))); // A predicate mixing the two belongs to the late pass, which can answer // both. Ownership is by membership, not by which leg matched. - EXPECT_TRUE (cfgpred::uses_layer( - R"(cfg(all(accelerator = "cuda", c-abi = "musl")))")); -} + expect(bool(cfgpred::uses_layer( + R"(cfg(all(accelerator = "cuda", c-abi = "musl")))"))); +}}; -TEST(CfgAccelerator, MisspellingIsReportedNotSilentlyFalse) { +UTest misspelling_is_reported_not_silently_false{"CfgAccelerator/MisspellingIsReportedNotSilentlyFalse", [] { // A predicate naming a token outside the vocabulary used to evaluate to // false in silence, which is indistinguishable from one that correctly did // not apply. auto unknown = cfgpred::unknown_tokens(R"(cfg(acclerator = "cuda"))"); - ASSERT_EQ(unknown.size(), 1u); - EXPECT_EQ(unknown[0], "acclerator"); - EXPECT_TRUE(cfgpred::unknown_tokens(R"(cfg(accelerator = "cuda"))").empty()); -} + expect(fatal(eq(unknown.size(), 1u))); + expect(eq(unknown[0], "acclerator")); + expect(cfgpred::unknown_tokens(R"(cfg(accelerator = "cuda"))").empty()); +}}; diff --git a/tests/unit/test_cfg_accelerator_none.cpp b/tests/unit/test_cfg_accelerator_none.cpp index 5c1f6e287..a03572b88 100644 --- a/tests/unit/test_cfg_accelerator_none.cpp +++ b/tests/unit/test_cfg_accelerator_none.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.prepare_inputs; +using namespace mcpplibs::mtest; + namespace cfgpred = mcpp::build::cfgpred; namespace { @@ -25,25 +27,25 @@ bool m(std::string_view predicate, const cfgpred::Ctx& c) { // package rather than an engine change. A fallback written by enumeration // therefore changes meaning the day a fifth backend exists, silently. -TEST(CfgAcceleratorNone, NoneIsTheEmptySet) { - EXPECT_TRUE (m(R"(cfg(accelerator = "none"))", with({}))); - EXPECT_FALSE(m(R"(cfg(accelerator = "none"))", with({"cuda"}))); - EXPECT_FALSE(m(R"(cfg(accelerator = "none"))", with({"cuda", "vulkan"}))); +UTest none_is_the_empty_set{"CfgAcceleratorNone/NoneIsTheEmptySet", [] { + expect(bool(m(R"(cfg(accelerator = "none"))", with({})))); + expect(!(m(R"(cfg(accelerator = "none"))", with({"cuda"})))); + expect(!(m(R"(cfg(accelerator = "none"))", with({"cuda", "vulkan"})))); // The negation is the other half a seam needs: "some device backend". - EXPECT_FALSE(m(R"(cfg(not(accelerator = "none")))", with({}))); - EXPECT_TRUE (m(R"(cfg(not(accelerator = "none")))", with({"vulkan"}))); -} + expect(!(m(R"(cfg(not(accelerator = "none")))", with({})))); + expect(bool(m(R"(cfg(not(accelerator = "none")))", with({"vulkan"})))); +}}; -TEST(CfgAcceleratorNone, NoneDoesNotDisturbMembership) { +UTest none_does_not_disturb_membership{"CfgAcceleratorNone/NoneDoesNotDisturbMembership", [] { // `none` must not become a member of the set it describes the emptiness // of, or `not(accelerator = "cuda")` would start answering for it. - EXPECT_TRUE (m(R"(cfg(accelerator = "cuda"))", with({"cuda"}))); - EXPECT_FALSE(m(R"(cfg(accelerator = "vulkan"))", with({"cuda"}))); + expect(bool(m(R"(cfg(accelerator = "cuda"))", with({"cuda"})))); + expect(!(m(R"(cfg(accelerator = "vulkan"))", with({"cuda"})))); // A build that named a backend literally spelled "none" is not a case this // engine has to serve; what matters is that the empty set stays the only // thing `none` reports, which the first test pins. -} +}}; // THE REASON THIS IS NOT A COSMETIC CHANGE. // @@ -51,19 +53,19 @@ TEST(CfgAcceleratorNone, NoneDoesNotDisturbMembership) { // added. That is the whole point, so the test simulates the addition rather // than describing it: `enumerated` is what a project wrote against the // two-backend world, `none` is what it should have written. -TEST(CfgAcceleratorNone, EnumerationRotsAndNoneDoesNot) { +UTest enumeration_rots_and_none_does_not{"CfgAcceleratorNone/EnumerationRotsAndNoneDoesNot", [] { constexpr auto enumerated = R"(cfg(not(any(accelerator = "cuda", accelerator = "vulkan"))))"; constexpr auto stable = R"(cfg(accelerator = "none"))"; // The world the fallback was written in: the two agree. - EXPECT_EQ(m(enumerated, with({})), m(stable, with({}))); - EXPECT_EQ(m(enumerated, with({"cuda"})), m(stable, with({"cuda"}))); + expect(eq(m(enumerated, with({})), m(stable, with({})))); + expect(eq(m(enumerated, with({"cuda"})), m(stable, with({"cuda"})))); // A fifth backend arrives. The enumeration now claims "no accelerator" for // a build that named one -- the CPU fallback would compile beside the // device implementation -- while `none` is unchanged. auto fifth = with({"ascend"}); - EXPECT_TRUE (m(enumerated, fifth)) << "the enumeration is expected to rot"; - EXPECT_FALSE(m(stable, fifth)) << "`none` must not rot"; -} + expect(bool(m(enumerated, fifth))) << "the enumeration is expected to rot"; + expect(!m(stable, fifth)) << "`none` must not rot"; +}}; diff --git a/tests/unit/test_cfg_os_only_platform.cpp b/tests/unit/test_cfg_os_only_platform.cpp index 7f1f0ea65..fbf76986c 100644 --- a/tests/unit/test_cfg_os_only_platform.cpp +++ b/tests/unit/test_cfg_os_only_platform.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.prepare_inputs; +using namespace mcpplibs::mtest; + // #630 item 7 — "a selector that names only an operating system is a // platform" (design record 2026-09-13-630 §8.2). `os_only_platforms` is the // function `mcpp emit xpkg` (`mcpp::pm::emit_xpkg`) asks to decide whether a @@ -27,109 +29,109 @@ bool has(const std::vector& v, std::string_view a) { // ── Positive: the seven OS-only forms ─────────────────────────────────────── -TEST(OsOnlyPlatforms, BareAliasWindows) { +UTest bare_alias_windows{"OsOnlyPlatforms/BareAliasWindows", [] { auto p = cfgpred::os_only_platforms("windows"); - EXPECT_EQ(p, std::vector({"windows"})); -} + expect(eq(p, std::vector({"windows"}))); +}}; -TEST(OsOnlyPlatforms, CfgWindows) { +UTest cfg_windows{"OsOnlyPlatforms/CfgWindows", [] { auto p = cfgpred::os_only_platforms("cfg(windows)"); - EXPECT_EQ(p, std::vector({"windows"})); -} + expect(eq(p, std::vector({"windows"}))); +}}; -TEST(OsOnlyPlatforms, CfgOsEqualsLinux) { +UTest cfg_os_equals_linux{"OsOnlyPlatforms/CfgOsEqualsLinux", [] { auto p = cfgpred::os_only_platforms(R"(cfg(os = "linux"))"); - EXPECT_EQ(p, std::vector({"linux"})); -} + expect(eq(p, std::vector({"linux"}))); +}}; -TEST(OsOnlyPlatforms, CfgLinux) { +UTest cfg_linux{"OsOnlyPlatforms/CfgLinux", [] { auto p = cfgpred::os_only_platforms("cfg(linux)"); - EXPECT_EQ(p, std::vector({"linux"})); -} + expect(eq(p, std::vector({"linux"}))); +}}; -TEST(OsOnlyPlatforms, CfgMacos) { +UTest cfg_macos{"OsOnlyPlatforms/CfgMacos", [] { auto p = cfgpred::os_only_platforms("cfg(macos)"); // The descriptor's spelling is `macosx`, not `macos` — see // `mcpp::pm::emit_xpkg`'s three blocks. - EXPECT_EQ(p, std::vector({"macosx"})); -} + expect(eq(p, std::vector({"macosx"}))); +}}; -TEST(OsOnlyPlatforms, CfgOsEqualsWindows) { +UTest cfg_os_equals_windows{"OsOnlyPlatforms/CfgOsEqualsWindows", [] { auto p = cfgpred::os_only_platforms(R"(cfg(os = "windows"))"); - EXPECT_EQ(p, std::vector({"windows"})); -} + expect(eq(p, std::vector({"windows"}))); +}}; -TEST(OsOnlyPlatforms, CfgOsEqualsMacos) { +UTest cfg_os_equals_macos{"OsOnlyPlatforms/CfgOsEqualsMacos", [] { auto p = cfgpred::os_only_platforms(R"(cfg(os = "macos"))"); - EXPECT_EQ(p, std::vector({"macosx"})); -} + expect(eq(p, std::vector({"macosx"}))); +}}; -TEST(OsOnlyPlatforms, CfgUnixNamesBothMacAndLinux) { +UTest cfg_unix_names_both_mac_and_linux{"OsOnlyPlatforms/CfgUnixNamesBothMacAndLinux", [] { // `unix` means `family == "unix"`, which macOS and Linux both satisfy — // the only one of the seven forms that names two blocks at once. auto p = cfgpred::os_only_platforms("cfg(unix)"); - EXPECT_EQ(p.size(), 2u); - EXPECT_TRUE(has(p, "linux")); - EXPECT_TRUE(has(p, "macosx")); - EXPECT_FALSE(has(p, "windows")); -} + expect(eq(p.size(), 2u)); + expect(bool(has(p, "linux"))); + expect(bool(has(p, "macosx"))); + expect(!has(p, "windows")); +}}; -TEST(OsOnlyPlatforms, BareAliasUnix) { +UTest bare_alias_unix{"OsOnlyPlatforms/BareAliasUnix", [] { auto p = cfgpred::os_only_platforms("unix"); - EXPECT_EQ(p.size(), 2u); - EXPECT_TRUE(has(p, "linux")); - EXPECT_TRUE(has(p, "macosx")); -} + expect(eq(p.size(), 2u)); + expect(bool(has(p, "linux"))); + expect(bool(has(p, "macosx"))); +}}; // ── Negative: everything the record says keeps the warning ───────────────── -TEST(OsOnlyPlatforms, ArchitectureIsNotOsOnly) { +UTest architecture_is_not_os_only{"OsOnlyPlatforms/ArchitectureIsNotOsOnly", [] { auto p = cfgpred::os_only_platforms(R"(cfg(target_arch = "aarch64"))"); - EXPECT_TRUE(p.empty()); -} + expect(p.empty()); +}}; -TEST(OsOnlyPlatforms, CombinatorOfOsAndArchIsNotOsOnly) { +UTest combinator_of_os_and_arch_is_not_os_only{"OsOnlyPlatforms/CombinatorOfOsAndArchIsNotOsOnly", [] { auto p = cfgpred::os_only_platforms( R"(cfg(all(linux, target_arch = "x86_64")))"); - EXPECT_TRUE(p.empty()); -} + expect(p.empty()); +}}; -TEST(OsOnlyPlatforms, NotWindowsIsNotOsOnly) { +UTest not_windows_is_not_os_only{"OsOnlyPlatforms/NotWindowsIsNotOsOnly", [] { // Syntactically this names only an OS, but `not(...)` answers an // unbounded set of platforms (everything this vocabulary does not yet // call "windows"), not the two blocks "linux and macosx" would be if this // were treated as their union. Kept conservative: any combinator // disqualifies, `not` included. auto p = cfgpred::os_only_platforms("cfg(not(windows))"); - EXPECT_TRUE(p.empty()); -} + expect(p.empty()); +}}; -TEST(OsOnlyPlatforms, EnvIsNotOsOnly) { +UTest env_is_not_os_only{"OsOnlyPlatforms/EnvIsNotOsOnly", [] { auto p = cfgpred::os_only_platforms(R"(cfg(env = "android"))"); - EXPECT_TRUE(p.empty()); -} + expect(p.empty()); +}}; -TEST(OsOnlyPlatforms, LayerKeyIsNotOsOnly) { +UTest layer_key_is_not_os_only{"OsOnlyPlatforms/LayerKeyIsNotOsOnly", [] { auto p = cfgpred::os_only_platforms(R"(cfg(compiler = "llvm"))"); - EXPECT_TRUE(p.empty()); -} + expect(p.empty()); +}}; -TEST(OsOnlyPlatforms, AnAllOsCombinatorIsStillNotOsOnly) { +UTest an_all_os_combinator_is_still_not_os_only{"OsOnlyPlatforms/AnAllOsCombinatorIsStillNotOsOnly", [] { // Even a combinator built ENTIRELY from OS terms is refused — see the // comment on `os_only_platforms` for why `any(linux, macos)` is not the // same claim as either block alone. auto p = cfgpred::os_only_platforms("cfg(any(linux, macos))"); - EXPECT_TRUE(p.empty()); -} + expect(p.empty()); +}}; -TEST(OsOnlyPlatforms, ExactTripleIsNotOsOnly) { +UTest exact_triple_is_not_os_only{"OsOnlyPlatforms/ExactTripleIsNotOsOnly", [] { // An exact triple shares no grammar with cfg()/the bare aliases; it names // one target, not a platform. auto p = cfgpred::os_only_platforms("x86_64-linux-musl"); - EXPECT_TRUE(p.empty()); -} + expect(p.empty()); +}}; -TEST(OsOnlyPlatforms, UnrecognisedOsValueIsNotOsOnly) { +UTest unrecognised_os_value_is_not_os_only{"OsOnlyPlatforms/UnrecognisedOsValueIsNotOsOnly", [] { auto p = cfgpred::os_only_platforms(R"(cfg(os = "freebsd"))"); - EXPECT_TRUE(p.empty()); -} + expect(p.empty()); +}}; diff --git a/tests/unit/test_cmdlimits.cpp b/tests/unit/test_cmdlimits.cpp index 8aaec1c67..be4963a2e 100644 --- a/tests/unit/test_cmdlimits.cpp +++ b/tests/unit/test_cmdlimits.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.cmdlimits; +using namespace mcpplibs::mtest; + // The command-length budget, as data. Seven builds have died because a command // outgrew a ceiling nobody tracked, and each was found by crashing at the end // of a build with an error that named neither the edge nor the cause. These @@ -15,89 +17,88 @@ namespace cl = mcpp::build::cmdlimits; namespace { -TEST(CmdLimits, EveryChannelIsInTheTable) { +UTest every_channel_is_in_the_table{"CmdLimits/EveryChannelIsInTheTable", [] { // A channel that is not in the table has no budget, which is exactly the // state that produced this defect family seven times. `limit_of` must find // a real row for each enumerator, not fall through to the placeholder. for (auto c : {cl::Channel::NinjaArgv, cl::Channel::PosixShell, cl::Channel::CmdWrapper, cl::Channel::RspContent, cl::Channel::RspLine}) { - EXPECT_EQ(cl::limit_of(c).channel, c); - EXPECT_FALSE(cl::limit_of(c).imposedBy.empty()); + expect(eq(cl::limit_of(c).channel, c)); + expect(!(cl::limit_of(c).imposedBy.empty())); } -} +}}; -TEST(CmdLimits, NumbersAreTheRealOnes) { +UTest numbers_are_the_real_ones{"CmdLimits/NumbersAreTheRealOnes", [] { // Each of these was learned the expensive way; a "tidier" number would be // wrong. MAX_ARG_STRLEN is 32 pages — NOT the 2 MiB ARG_MAX that #344 // spent a release believing in. - EXPECT_EQ(cl::limit_of(cl::Channel::PosixShell).bytes, 128u * 1024u); - EXPECT_EQ(cl::limit_of(cl::Channel::NinjaArgv).bytes, 32u * 1024u); - EXPECT_EQ(cl::limit_of(cl::Channel::CmdWrapper).bytes, 8191u); - EXPECT_EQ(cl::limit_of(cl::Channel::RspLine).bytes, 128u * 1024u); -} + expect(eq(cl::limit_of(cl::Channel::PosixShell).bytes, 128u * 1024u)); + expect(eq(cl::limit_of(cl::Channel::NinjaArgv).bytes, 32u * 1024u)); + expect(eq(cl::limit_of(cl::Channel::CmdWrapper).bytes, 8191u)); + expect(eq(cl::limit_of(cl::Channel::RspLine).bytes, 128u * 1024u)); +}}; -TEST(CmdLimits, EverySymptomIsRecorded) { +UTest every_symptom_is_recorded{"CmdLimits/EverySymptomIsRecorded", [] { // The costly part of this family has never been the fix — it has been // RECOGNISING it. `Argument list too long`, `LNK1170` and a bare 127 have // nothing in common, so each row has to carry what the user actually sees. for (auto c : {cl::Channel::NinjaArgv, cl::Channel::PosixShell, cl::Channel::CmdWrapper, cl::Channel::RspLine}) { auto const& l = cl::limit_of(c); - EXPECT_FALSE(l.symptom.empty()) << "channel has no symptom recorded"; - EXPECT_FALSE(l.remedy.empty()) << "channel has no remedy recorded"; + expect(!l.symptom.empty()) << "channel has no symptom recorded"; + expect(!l.remedy.empty()) << "channel has no remedy recorded"; } -} +}}; -TEST(CmdLimits, ShellIsTighterThanDirectSpawnOnWindows) { +UTest shell_is_tighter_than_direct_spawn_on_windows{"CmdLimits/ShellIsTighterThanDirectSpawnOnWindows", [] { // cmd.exe is a quarter of CreateProcess, which is why #261's redirection // (it forced a `cmd /c` wrapper) failed where the direct spawn would have // been fine. - EXPECT_LT(cl::inline_budget(/*win=*/true, /*shell=*/true), - cl::inline_budget(/*win=*/true, /*shell=*/false)); -} + expect(lt(cl::inline_budget(/*win=*/true, /*shell=*/true), cl::inline_budget(/*win=*/true, /*shell=*/false))); +}}; -TEST(CmdLimits, UnderBudgetPasses) { - EXPECT_FALSE(cl::check_inline(std::string(1000, 'x'), false, true)); - EXPECT_FALSE(cl::check_inline(std::string(1000, 'x'), true, false)); -} +UTest under_budget_passes{"CmdLimits/UnderBudgetPasses", [] { + expect(!(cl::check_inline(std::string(1000, 'x'), false, true))); + expect(!(cl::check_inline(std::string(1000, 'x'), true, false))); +}}; -TEST(CmdLimits, OverBudgetIsReportedWithBothNumbers) { +UTest over_budget_is_reported_with_both_numbers{"CmdLimits/OverBudgetIsReportedWithBothNumbers", [] { // POSIX: ninja wraps in `sh -c`, so the whole command is one argv entry. auto over = cl::check_inline(std::string(200u * 1024u, 'x'), false, true); - ASSERT_TRUE(over); - EXPECT_EQ(over->channel, cl::Channel::PosixShell); - EXPECT_EQ(over->actual, 200u * 1024u); - EXPECT_EQ(over->allowed, 128u * 1024u); -} + expect(fatal(bool(over))); + expect(eq(over->channel, cl::Channel::PosixShell)); + expect(eq(over->actual, 200u * 1024u)); + expect(eq(over->allowed, 128u * 1024u)); +}}; -TEST(CmdLimits, WindowsPicksTheChannelItActuallyTravels) { +UTest windows_picks_the_channel_it_actually_travels{"CmdLimits/WindowsPicksTheChannelItActuallyTravels", [] { // 50 781 bytes is #274's real measurement (FFmpeg's 2281 units): fine for // CreateProcess, fatal through cmd.exe. const std::string cmd(50781, 'x'); auto direct = cl::check_inline(cmd, /*win=*/true, /*shell=*/false); auto shell = cl::check_inline(cmd, /*win=*/true, /*shell=*/true); - ASSERT_TRUE(direct); - EXPECT_EQ(direct->channel, cl::Channel::NinjaArgv); - ASSERT_TRUE(shell); - EXPECT_EQ(shell->channel, cl::Channel::CmdWrapper); -} + expect(fatal(bool(direct))); + expect(eq(direct->channel, cl::Channel::NinjaArgv)); + expect(fatal(bool(shell))); + expect(eq(shell->channel, cl::Channel::CmdWrapper)); +}}; -TEST(CmdLimits, DiagnosticNamesTheEdgeTheNumbersAndTheWayOut) { +UTest diagnostic_names_the_edge_the_numbers_and_the_way_out{"CmdLimits/DiagnosticNamesTheEdgeTheNumbersAndTheWayOut", [] { // Every underlying error omits the edge; that omission is what made these // expensive. The message must supply it, plus enough to act without // reading the source. auto over = cl::check_inline(std::string(200u * 1024u, 'x'), false, true); - ASSERT_TRUE(over); + expect(fatal(bool(over))); const std::string msg = cl::explain("build edge 'bin/app' (rule cxx_link)", *over); - EXPECT_NE(msg.find("bin/app"), std::string::npos); - EXPECT_NE(msg.find("cxx_link"), std::string::npos); - EXPECT_NE(msg.find("204800"), std::string::npos); // actual - EXPECT_NE(msg.find("131072"), std::string::npos); // allowed - EXPECT_NE(msg.find("MAX_ARG_STRLEN"), std::string::npos); - EXPECT_NE(msg.find("response file"), std::string::npos); - EXPECT_NE(msg.find("2026-08-06-command-length-architecture"), std::string::npos); -} + expect(ne(msg.find("bin/app"), std::string::npos)); + expect(ne(msg.find("cxx_link"), std::string::npos)); + expect(ne(msg.find("204800"), std::string::npos)); // actual + expect(ne(msg.find("131072"), std::string::npos)); // allowed + expect(ne(msg.find("MAX_ARG_STRLEN"), std::string::npos)); + expect(ne(msg.find("response file"), std::string::npos)); + expect(ne(msg.find("2026-08-06-command-length-architecture"), std::string::npos)); +}}; } // namespace diff --git a/tests/unit/test_coff_exports.cpp b/tests/unit/test_coff_exports.cpp index f886cd6d4..1a7dd03fc 100644 --- a/tests/unit/test_coff_exports.cpp +++ b/tests/unit/test_coff_exports.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.coff_exports; +using namespace mcpplibs::mtest; + using mcpp::build::coff::Export; using mcpp::build::coff::read_exports; using mcpp::build::coff::write_def; @@ -91,76 +93,76 @@ std::vector names(const std::vector& v) { // ─── what is exported ────────────────────────────────────────────────────── -TEST(CoffExports, TakesExternalDefinedSymbols) { +UTest takes_external_defined_symbols{"CoffExports/TakesExternalDefinedSymbols", [] { auto obj = make_obj({ Sym{ .name = "f" } }); auto r = read_exports(obj); - ASSERT_TRUE(r) << r.error(); - EXPECT_EQ(names(*r), (std::vector{ "f" })); -} + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << r.error(); + expect(eq(names(*r), (std::vector{ "f" }))); +}}; -TEST(CoffExports, SkipsStaticSymbols) { +UTest skips_static_symbols{"CoffExports/SkipsStaticSymbols", [] { // IMAGE_SYM_CLASS_STATIC (3). Exporting a TU-local symbol would put a name // in the DLL's surface that the author never made public. auto obj = make_obj({ Sym{ .name = "s", .storage = 3 } }); auto r = read_exports(obj); - ASSERT_TRUE(r); - EXPECT_TRUE(r->empty()); -} + expect(fatal(bool(r))); + expect(r->empty()); +}}; -TEST(CoffExports, SkipsUndefinedSymbols) { +UTest skips_undefined_symbols{"CoffExports/SkipsUndefinedSymbols", [] { // SectionNumber 0 is UNDEFINED — a symbol this object REFERENCES. Exporting // it would have the DLL claim to provide what it is asking someone else for. auto obj = make_obj({ Sym{ .name = "u", .section = 0 } }); auto r = read_exports(obj); - ASSERT_TRUE(r); - EXPECT_TRUE(r->empty()); -} + expect(fatal(bool(r))); + expect(r->empty()); +}}; -TEST(CoffExports, SkipsAbsoluteAndDebugSections) { +UTest skips_absolute_and_debug_sections{"CoffExports/SkipsAbsoluteAndDebugSections", [] { // -1 IMAGE_SYM_ABSOLUTE, -2 IMAGE_SYM_DEBUG: neither has an address to // export. auto obj = make_obj({ Sym{ .name = "a", .section = -1 }, Sym{ .name = "d", .section = -2 } }); auto r = read_exports(obj); - ASSERT_TRUE(r); - EXPECT_TRUE(r->empty()); -} + expect(fatal(bool(r))); + expect(r->empty()); +}}; // ─── DATA, which is the half that fails silently ─────────────────────────── -TEST(CoffExports, AVariableIsMarkedDATA) { +UTest a_variable_is_marked_data{"CoffExports/AVariableIsMarkedDATA", [] { // Without `DATA` the linker generates a call thunk for a variable and the // consumer reads the thunk instead of the value. auto obj = make_obj({ Sym{ .name = "v", .section = 2, .type = 0 } }, { kText, kData }); auto r = read_exports(obj); - ASSERT_TRUE(r); - ASSERT_EQ(r->size(), 1u); - EXPECT_TRUE((*r)[0].data); -} + expect(fatal(bool(r))); + expect(fatal(eq(r->size(), 1u))); + expect(bool((*r)[0].data)); +}}; -TEST(CoffExports, AConstInAReadOnlySectionIsStillDATA) { +UTest a_const_in_a_read_only_section_is_still_data{"CoffExports/AConstInAReadOnlySectionIsStillDATA", [] { // `.rdata` is not writable, so a rule keyed on MEM_WRITE alone calls this a // function. It is a variable, and consumers read it as one. auto obj = make_obj({ Sym{ .name = "c", .section = 2, .type = 0 } }, { kText, kRdata }); auto r = read_exports(obj); - ASSERT_TRUE(r); - ASSERT_EQ(r->size(), 1u); - EXPECT_TRUE((*r)[0].data); -} + expect(fatal(bool(r))); + expect(fatal(eq(r->size(), 1u))); + expect(bool((*r)[0].data)); +}}; -TEST(CoffExports, AFunctionIsNotDATA) { +UTest a_function_is_not_data{"CoffExports/AFunctionIsNotDATA", [] { auto obj = make_obj({ Sym{ .name = "f", .section = 1, .type = 0x20 } }, { kText }); auto r = read_exports(obj); - ASSERT_TRUE(r); - ASSERT_EQ(r->size(), 1u); - EXPECT_FALSE((*r)[0].data); -} + expect(fatal(bool(r))); + expect(fatal(eq(r->size(), 1u))); + expect(!((*r)[0].data)); +}}; // ─── the skip list, each entry for its own reason ────────────────────────── -TEST(CoffExports, SkipsDestructorThunksAndManagedArtefacts) { +UTest skips_destructor_thunks_and_managed_artefacts{"CoffExports/SkipsDestructorThunksAndManagedArtefacts", [] { auto obj = make_obj({ Sym{ .name = "??_Gx" }, // scalar-deleting destructor Sym{ .name = "??_Ex" }, // vector-deleting destructor @@ -168,11 +170,11 @@ TEST(CoffExports, SkipsDestructorThunksAndManagedArtefacts) { Sym{ .name = "keep" }, }); auto r = read_exports(obj); - ASSERT_TRUE(r); - EXPECT_EQ(names(*r), (std::vector{ "keep" })); -} + expect(fatal(bool(r))); + expect(eq(names(*r), (std::vector{ "keep" }))); +}}; -TEST(CoffExports, SkipsAuxiliaryRecords) { +UTest skips_auxiliary_records{"CoffExports/SkipsAuxiliaryRecords", [] { // An aux record follows its symbol and is NOT a symbol. Reading one as a // symbol yields a name made of raw section data — which is how a `.def` // ends up with entries no linker can resolve. @@ -180,39 +182,39 @@ TEST(CoffExports, SkipsAuxiliaryRecords) { Sym{ .name = "\x01\x02\x03" }, // the aux record Sym{ .name = "after" } }); auto r = read_exports(obj); - ASSERT_TRUE(r); - EXPECT_EQ(names(*r), (std::vector{ "after", "fn" })); -} + expect(fatal(bool(r))); + expect(eq(names(*r), (std::vector{ "after", "fn" }))); +}}; -TEST(CoffExports, StripsTheLeadingUnderscoreOnI386Only) { +UTest strips_the_leading_underscore_on_i386_only{"CoffExports/StripsTheLeadingUnderscoreOnI386Only", [] { auto i386 = make_obj({ Sym{ .name = "_f" } }, { kText }, 0x014c); auto r32 = read_exports(i386); - ASSERT_TRUE(r32); - EXPECT_EQ(names(*r32), (std::vector{ "f" })); + expect(fatal(bool(r32))); + expect(eq(names(*r32), (std::vector{ "f" }))); // On amd64 a leading underscore is part of the name. auto amd64 = make_obj({ Sym{ .name = "_f" } }, { kText }, 0x8664); auto r64 = read_exports(amd64); - ASSERT_TRUE(r64); - EXPECT_EQ(names(*r64), (std::vector{ "_f" })); -} + expect(fatal(bool(r64))); + expect(eq(names(*r64), (std::vector{ "_f" }))); +}}; // ─── refusals, because a partial answer here ships a broken DLL ──────────── -TEST(CoffExports, RefusesAnUnsupportedMachine) { +UTest refuses_an_unsupported_machine{"CoffExports/RefusesAnUnsupportedMachine", [] { auto obj = make_obj({ Sym{ .name = "f" } }, { kText }, 0x5032 /* RISC-V */); auto r = read_exports(obj); - ASSERT_FALSE(r); - EXPECT_NE(r.error().find("machine"), std::string::npos); -} + expect(fatal(!r)); + expect(ne(r.error().find("machine"), std::string::npos)); +}}; -TEST(CoffExports, RefusesATruncatedSymbolTable) { +UTest refuses_a_truncated_symbol_table{"CoffExports/RefusesATruncatedSymbolTable", [] { auto obj = make_obj({ Sym{ .name = "f" }, Sym{ .name = "g" } }); obj.resize(obj.size() - 20); // cut into the symbol table auto r = read_exports(obj); - ASSERT_FALSE(r) << "a truncated table must not read as a short one"; - EXPECT_NE(r.error().find("past the end"), std::string::npos); -} + expect(fatal(!r)) << "a truncated table must not read as a short one"; + expect(ne(r.error().find("past the end"), std::string::npos)); +}}; // ─── anonymous objects (2026.10.5.2) ──────────────────────────────────────── // @@ -268,7 +270,7 @@ std::vector make_bigobj(std::vector syms, } // namespace -TEST(CoffExports, ReadsABigobjObjectAsTheOrdinaryOneWithTheSameSymbols) { +UTest reads_a_bigobj_object_as_the_ordinary_one_with_the_same_symbols{"CoffExports/ReadsABigobjObjectAsTheOrdinaryOneWithTheSameSymbols", [] { const std::vector syms = { Sym{ .name = "f" }, Sym{ .name = "v", .section = 2, .type = 0 }, Sym{ .name = "s", .storage = 3 }, Sym{ .name = "u", .section = 0 }, @@ -276,65 +278,64 @@ TEST(CoffExports, ReadsABigobjObjectAsTheOrdinaryOneWithTheSameSymbols) { }; auto small = read_exports(make_obj(syms, { kText, kData })); auto big = read_exports(make_bigobj(syms, { kText, kData })); - ASSERT_TRUE(small) << small.error(); - ASSERT_TRUE(big) << big.error(); - EXPECT_EQ(*big, *small); - EXPECT_EQ(names(*big), (std::vector{ "f", "g", "h", "v" })); -} + if (auto&& failed = expect(fatal(bool(small))); !failed) failed << small.error(); + if (auto&& failed = expect(fatal(bool(big))); !failed) failed << big.error(); + expect(eq(*big, *small)); + expect(eq(names(*big), (std::vector{ "f", "g", "h", "v" }))); +}}; -TEST(CoffExports, NamesAGlObjectAndSaysWhatToDo) { +UTest names_a_gl_object_and_says_what_to_do{"CoffExports/NamesAGlObjectAndSaysWhatToDo", [] { auto r = read_exports(anonymous(1, kClGl)); - ASSERT_FALSE(r); - EXPECT_NE(r.error().find("/GL"), std::string::npos) << r.error(); - EXPECT_EQ(r.error().find("bigobj"), std::string::npos) << r.error(); - EXPECT_NE(r.error().find("windows_auto_export = false"), std::string::npos); - EXPECT_NE(r.error().find("dllexport"), std::string::npos); - EXPECT_FALSE(declares_exports(anonymous(1, kClGl))); -} - -TEST(CoffExports, NamesAnImportObjectAndAnUnknownAnonymousObject) { + expect(fatal(!r)); + if (auto&& failed = expect(ne(r.error().find("/GL"), std::string::npos)); !failed) failed << r.error(); + if (auto&& failed = expect(eq(r.error().find("bigobj"), std::string::npos)); !failed) failed << r.error(); + expect(ne(r.error().find("windows_auto_export = false"), std::string::npos)); + expect(ne(r.error().find("dllexport"), std::string::npos)); + expect(!(declares_exports(anonymous(1, kClGl)))); +}}; + +UTest names_an_import_object_and_an_unknown_anonymous_object{"CoffExports/NamesAnImportObjectAndAnUnknownAnonymousObject", [] { auto imp = read_exports(anonymous(0, {})); - ASSERT_FALSE(imp); - EXPECT_NE(imp.error().find("import object"), std::string::npos) << imp.error(); + expect(fatal(!imp)); + if (auto&& failed = expect(ne(imp.error().find("import object"), std::string::npos)); !failed) failed << imp.error(); auto other = read_exports(anonymous(3, {})); - ASSERT_FALSE(other); - EXPECT_NE(other.error().find("anonymous"), std::string::npos) << other.error(); -} + expect(fatal(!other)); + if (auto&& failed = expect(ne(other.error().find("anonymous"), std::string::npos)); !failed) failed << other.error(); +}}; -TEST(CoffExports, RefusesABigobjObjectWhoseCountsOverrunIt) { +UTest refuses_a_bigobj_object_whose_counts_overrun_it{"CoffExports/RefusesABigobjObjectWhoseCountsOverrunIt", [] { auto obj = make_bigobj({ Sym{ .name = "f" } }); put32(obj, 44, 0x00ffffffu); // sections no file of this size can hold auto r = read_exports(obj); - ASSERT_FALSE(r); - EXPECT_NE(r.error().find("past the end"), std::string::npos) << r.error(); -} + expect(fatal(!r)); + if (auto&& failed = expect(ne(r.error().find("past the end"), std::string::npos)); !failed) failed << r.error(); +}}; -TEST(CoffExports, RefusesSomethingThatIsNotAnObject) { +UTest refuses_something_that_is_not_an_object{"CoffExports/RefusesSomethingThatIsNotAnObject", [] { std::vector tiny(4, std::byte{0}); - EXPECT_FALSE(read_exports(tiny)); -} + expect(!read_exports(tiny)); +}}; // ─── the .def text ───────────────────────────────────────────────────────── -TEST(CoffExports, DefIsSortedDeduplicatedAndMarksData) { +UTest def_is_sorted_deduplicated_and_marks_data{"CoffExports/DefIsSortedDeduplicatedAndMarksData", [] { auto def = write_def("mathkit.dll", { { "zeta", false }, { "alpha", true }, { "zeta", false } }); - EXPECT_EQ(def, - "LIBRARY mathkit.dll\n" + expect(eq(def, "LIBRARY mathkit.dll\n" "EXPORTS\n" " alpha DATA\n" - " zeta\n"); -} + " zeta\n")); +}}; -TEST(CoffExports, TheExportCeilingIsPesNotOurs) { +UTest the_export_ceiling_is_pes_not_ours{"CoffExports/TheExportCeilingIsPesNotOurs", [] { // Stated as a constant a reader can check against the format, not as a // number chosen here: PE addresses exports by 16-bit ordinal. - EXPECT_EQ(kMaxExports, 65535u); -} + expect(eq(kMaxExports, 65535u)); +}}; // ─── a real object, from a real compiler ─────────────────────────────────── -TEST(CoffExports, ReadsARealMingwObject) { +UTest reads_a_real_mingw_object{"CoffExports/ReadsARealMingwObject", [] { // Committed fixture: macOS and Windows CI cannot produce a COFF object, and // a byte-level reader that is only ever fed its own test's output is exactly // the kind that agrees with itself and with nothing else. @@ -347,31 +348,31 @@ TEST(CoffExports, ReadsARealMingwObject) { for (char c; in.get(c); ) bytes.push_back(static_cast(c)); auto r = read_exports(bytes); - ASSERT_TRUE(r) << r.error(); + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << r.error(); auto got = names(*r); // From `x86_64-w64-mingw32-nm --defined-only --extern-only` on the same // object; see tests/fixtures/coff/README.md. - EXPECT_NE(std::ranges::find(got, "exported_fn"), got.end()); - EXPECT_NE(std::ranges::find(got, "exported_data"), got.end()); - EXPECT_NE(std::ranges::find(got, "exported_const"), got.end()); - EXPECT_NE(std::ranges::find(got, "_ZN2ns10mangled_fnEi"), got.end()); + expect(ne(std::ranges::find(got, "exported_fn"), got.end())); + expect(ne(std::ranges::find(got, "exported_data"), got.end())); + expect(ne(std::ranges::find(got, "exported_const"), got.end())); + expect(ne(std::ranges::find(got, "_ZN2ns10mangled_fnEi"), got.end())); // `static` at namespace scope is internal linkage: not in the surface. - EXPECT_EQ(std::ranges::find(got, "_ZL9hidden_fni"), got.end()); + expect(eq(std::ranges::find(got, "_ZL9hidden_fni"), got.end())); for (auto const& e : *r) { if (e.name == "exported_data" || e.name == "exported_const") - EXPECT_TRUE(e.data) << e.name << " is a variable"; - if (e.name == "exported_fn") EXPECT_FALSE(e.data); + expect(bool(e.data)) << e.name << " is a variable"; + if (e.name == "exported_fn") expect(!e.data); } return; } - GTEST_SKIP() << "fixture not reachable from this working directory"; -} + skip("fixture not reachable from this working directory"); +}}; // ─── annotation wins ─────────────────────────────────────────────────────── -TEST(CoffExports, RecognisesAnObjectThatAlreadyDeclaresExports) { +UTest recognises_an_object_that_already_declares_exports{"CoffExports/RecognisesAnObjectThatAlreadyDeclaresExports", [] { // `__declspec(dllexport)` makes the compiler write `/EXPORT:` directives // into `.drectve`. Generating a `.def` on top of that would export the same // names twice (LNK4197) AND export everything else besides, replacing a @@ -387,13 +388,13 @@ TEST(CoffExports, RecognisesAnObjectThatAlreadyDeclaresExports) { if (!in) continue; std::vector bytes; for (char c; in.get(c); ) bytes.push_back(static_cast(c)); - EXPECT_TRUE(declares_exports(bytes)); + expect(bool(declares_exports(bytes))); return; } - GTEST_SKIP() << "fixture not reachable from this working directory"; -} + skip("fixture not reachable from this working directory"); +}}; -TEST(CoffExports, AnObjectWithNoDirectivesDeclaresNothing) { +UTest an_object_with_no_directives_declares_nothing{"CoffExports/AnObjectWithNoDirectivesDeclaresNothing", [] { // The negative half: without it, a `declares_exports` that always said true // would pass the test above and silently disable auto-export everywhere. for (auto const& base : { "tests/fixtures/coff/probe-amd64.obj", @@ -403,12 +404,12 @@ TEST(CoffExports, AnObjectWithNoDirectivesDeclaresNothing) { if (!in) continue; std::vector bytes; for (char c; in.get(c); ) bytes.push_back(static_cast(c)); - EXPECT_FALSE(declares_exports(bytes)); + expect(!declares_exports(bytes)); return; } - GTEST_SKIP() << "fixture not reachable from this working directory"; -} + skip("fixture not reachable from this working directory"); +}}; -TEST(CoffExports, SyntheticObjectsHaveNoDirectiveSection) { - EXPECT_FALSE(declares_exports(make_obj({ Sym{ .name = "f" } }))); -} +UTest synthetic_objects_have_no_directive_section{"CoffExports/SyntheticObjectsHaveNoDirectiveSection", [] { + expect(!(declares_exports(make_obj({ Sym{ .name = "f" } })))); +}}; diff --git a/tests/unit/test_command_words.cpp b/tests/unit/test_command_words.cpp index da91a5cb9..8709c6601 100644 --- a/tests/unit/test_command_words.cpp +++ b/tests/unit/test_command_words.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.command_line; +using namespace mcpplibs::mtest; + // The two readers of a command's words: a POSIX shell's, for the link flags as // ninja runs them, and a response file's, as GCC, Clang and their linkers read // one (`@`). @@ -11,23 +13,22 @@ using words = std::vector; using mcpp::build::command_line::response_file_words; using mcpp::build::command_line::shell_words; -TEST(CommandWords, AShellReadsQuotesAndBackslashes) { - EXPECT_EQ(shell_words(" -o 'a b' \"c d\" e\\ f "), (words{"-o", "a b", "c d", "e f"})); - EXPECT_EQ(shell_words("'a\\b'"), (words{"a\\b"})); - EXPECT_EQ(shell_words("\"a\\b\\\"c\""), (words{"a\\b\"c"})); - EXPECT_EQ(shell_words("'it'\\''s'"), (words{"it's"})); - EXPECT_EQ(shell_words("a\\\nb"), (words{"ab"})); - EXPECT_EQ(shell_words("''"), (words{""})); - EXPECT_EQ(shell_words(" \t\n"), (words{})); -} +UTest a_shell_reads_quotes_and_backslashes{"CommandWords/AShellReadsQuotesAndBackslashes", [] { + expect(eq(shell_words(" -o 'a b' \"c d\" e\\ f "), (words{"-o", "a b", "c d", "e f"}))); + expect(eq(shell_words("'a\\b'"), (words{"a\\b"}))); + expect(eq(shell_words("\"a\\b\\\"c\""), (words{"a\\b\"c"}))); + expect(eq(shell_words("'it'\\''s'"), (words{"it's"}))); + expect(eq(shell_words("a\\\nb"), (words{"ab"}))); + expect(eq(shell_words("''"), (words{""}))); + expect(eq(shell_words(" \t\n"), (words{}))); +}}; -TEST(CommandWords, AResponseFileTakesTheCharacterAfterEveryBackslash) { - EXPECT_EQ(response_file_words("/a/lib.a\n\"/b c/lib.a\"\r\n'x\\'y'"), - (words{"/a/lib.a", "/b c/lib.a", "x'y"})); - EXPECT_EQ(response_file_words("a\\ b \"c\\\\d\""), (words{"a b", "c\\d"})); -} +UTest a_response_file_takes_the_character_after_every_backslash{"CommandWords/AResponseFileTakesTheCharacterAfterEveryBackslash", [] { + expect(eq(response_file_words("/a/lib.a\n\"/b c/lib.a\"\r\n'x\\'y'"), (words{"/a/lib.a", "/b c/lib.a", "x'y"}))); + expect(eq(response_file_words("a\\ b \"c\\\\d\""), (words{"a b", "c\\d"}))); +}}; -TEST(CommandWords, AWordQuotedForAShellReadsBackAsItself) { +UTest a_word_quoted_for_a_shell_reads_back_as_itself{"CommandWords/AWordQuotedForAShellReadsBackAsItself", [] { for (std::string w : {"plain", "a b", "it's", "a\\b", "$x", "\"q\"", ""}) - EXPECT_EQ(shell_words(mcpp::build::command_line::quote_posix(w)), words{w}) << w; -} + expect(eq(shell_words(mcpp::build::command_line::quote_posix(w)), words{w})) << w; +}}; diff --git a/tests/unit/test_compile_commands.cpp b/tests/unit/test_compile_commands.cpp index e2a159080..589e2ab72 100644 --- a/tests/unit/test_compile_commands.cpp +++ b/tests/unit/test_compile_commands.cpp @@ -1,6 +1,6 @@ -#include import std; +import mcpplibs.mtest; import mcpp.source_kind; import mcpp.build.compile_commands; import mcpp.build.flags; @@ -10,6 +10,8 @@ import mcpp.manifest.flag_words; import mcpplibs.os.platform; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + using namespace mcpp::build; namespace { @@ -64,8 +66,8 @@ std::string cdb(std::initializer_list entries) { // (merge_compile_commands short-circuits to `fresh` on a discarded parse), // so the fixture itself is checked here rather than trusted. Tests that // WANT malformed input pass it directly, not through this builder. - EXPECT_FALSE(nlohmann::json::parse(s, nullptr, /*allow_exceptions=*/false) - .is_discarded()) << s; + expect(!(nlohmann::json::parse(s, nullptr, /*allow_exceptions=*/false) + .is_discarded())) << s; return s; } @@ -80,7 +82,7 @@ std::size_t count(std::string_view hay, std::string_view needle) { // The core regression guard: a plain `mcpp build` regenerates the CDB from a // plan that lacks test files, but it must NOT wipe out test entries a prior // `mcpp test` wrote — clangd would lose all completion in tests/ otherwise. -TEST(CompileCommandsMerge, PreservesPriorEntriesForFilesNotInFreshPlan) { +UTest preserves_prior_entries_for_files_not_in_fresh_plan{"CompileCommandsMerge/PreservesPriorEntriesForFilesNotInFreshPlan", [] { auto fresh = cdb({ entry("/p/src/main.cpp", "-O2-FRESH") }); auto existing = cdb({ entry("/p/src/main.cpp", "-O0-STALE"), entry("/p/tests/test_smoke.cpp", "-Igtest") }); @@ -89,16 +91,16 @@ TEST(CompileCommandsMerge, PreservesPriorEntriesForFilesNotInFreshPlan) { fresh, existing, [](const std::filesystem::path&) { return true; }); // main.cpp takes the fresh plan's flags, not the stale prior ones. - EXPECT_NE(merged.find("-O2-FRESH"), std::string::npos) << merged; - EXPECT_EQ(merged.find("-O0-STALE"), std::string::npos) << merged; + expect(ne(merged.find("-O2-FRESH"), std::string::npos)) << merged; + expect(eq(merged.find("-O0-STALE"), std::string::npos)) << merged; // The test entry, absent from the fresh plan, is preserved. - EXPECT_NE(merged.find("tests/test_smoke.cpp"), std::string::npos) << merged; - EXPECT_NE(merged.find("-Igtest"), std::string::npos) << merged; -} + expect(ne(merged.find("tests/test_smoke.cpp"), std::string::npos)) << merged; + expect(ne(merged.find("-Igtest"), std::string::npos)) << merged; +}}; // Prior entries for files that no longer exist on disk must be pruned, so the // CDB never accumulates dead references (e.g. a deleted test file). -TEST(CompileCommandsMerge, PrunesPriorEntriesWhoseFileNoLongerExists) { +UTest prunes_prior_entries_whose_file_no_longer_exists{"CompileCommandsMerge/PrunesPriorEntriesWhoseFileNoLongerExists", [] { auto fresh = cdb({ entry("/p/src/main.cpp", "-O2") }); auto existing = cdb({ entry("/p/tests/deleted.cpp", "-Igtest") }); @@ -106,21 +108,21 @@ TEST(CompileCommandsMerge, PrunesPriorEntriesWhoseFileNoLongerExists) { fresh, existing, [](const std::filesystem::path& p) { return p != "/p/tests/deleted.cpp"; }); - EXPECT_EQ(merged.find("deleted.cpp"), std::string::npos) << merged; -} + expect(eq(merged.find("deleted.cpp"), std::string::npos)) << merged; +}}; // Exactly one entry per file: the fresh plan wins, no duplicate accrues. -TEST(CompileCommandsMerge, FreshEntryWinsAndNoDuplicatePerFile) { +UTest fresh_entry_wins_and_no_duplicate_per_file{"CompileCommandsMerge/FreshEntryWinsAndNoDuplicatePerFile", [] { auto fresh = cdb({ entry("/p/a.cpp", "-FRESH") }); auto existing = cdb({ entry("/p/a.cpp", "-STALE") }); auto merged = merge_compile_commands( fresh, existing, [](const std::filesystem::path&) { return true; }); - EXPECT_EQ(count(merged, "/p/a.cpp"), 1u) << merged; - EXPECT_NE(merged.find("-FRESH"), std::string::npos) << merged; - EXPECT_EQ(merged.find("-STALE"), std::string::npos) << merged; -} + expect(eq(count(merged, "/p/a.cpp"), 1u)) << merged; + expect(ne(merged.find("-FRESH"), std::string::npos)) << merged; + expect(eq(merged.find("-STALE"), std::string::npos)) << merged; +}}; // §3.2 item 1: the within-configuration merge's identity is `file` resolved // against `directory`, not `file` alone. A prior entry with a RELATIVE `file` @@ -128,7 +130,7 @@ TEST(CompileCommandsMerge, FreshEntryWinsAndNoDuplicatePerFile) { // the standard-library units always have one, §3.3) must still be recognized // as the SAME file the fresh plan already covers, so it is dropped rather // than kept as a spurious duplicate. -TEST(CompileCommandsMerge, ResolvesFileAgainstDirectoryForTheDedupKey) { +UTest resolves_file_against_directory_for_the_dedup_key{"CompileCommandsMerge/ResolvesFileAgainstDirectoryForTheDedupKey", [] { auto fresh = cdb({ entry("/p/src/main.cpp", "-FRESH") }); nlohmann::json e; e["directory"] = "/p"; @@ -140,42 +142,42 @@ TEST(CompileCommandsMerge, ResolvesFileAgainstDirectoryForTheDedupKey) { auto merged = merge_compile_commands( fresh, existing, [](const std::filesystem::path&) { return true; }); - EXPECT_EQ(count(merged, "main.cpp"), 1u) << merged; - EXPECT_NE(merged.find("-FRESH"), std::string::npos) << merged; - EXPECT_EQ(merged.find("-STALE"), std::string::npos) << merged; -} + expect(eq(count(merged, "main.cpp"), 1u)) << merged; + expect(ne(merged.find("-FRESH"), std::string::npos)) << merged; + expect(eq(merged.find("-STALE"), std::string::npos)) << merged; +}}; // A malformed prior CDB must never break generation: fall back to fresh. -TEST(CompileCommandsMerge, MalformedExistingFallsBackToFresh) { +UTest malformed_existing_falls_back_to_fresh{"CompileCommandsMerge/MalformedExistingFallsBackToFresh", [] { auto fresh = cdb({ entry("/p/src/main.cpp", "-O2") }); auto merged = merge_compile_commands( fresh, "{ this is not valid json ][", [](const std::filesystem::path&) { return true; }); - EXPECT_NE(merged.find("src/main.cpp"), std::string::npos) << merged; - EXPECT_NE(merged.find("-O2"), std::string::npos) << merged; -} + expect(ne(merged.find("src/main.cpp"), std::string::npos)) << merged; + expect(ne(merged.find("-O2"), std::string::npos)) << merged; +}}; -TEST(CompileCommandsMerge, SortsFinalEntriesByFile) { +UTest sorts_final_entries_by_file{"CompileCommandsMerge/SortsFinalEntriesByFile", [] { auto fresh = cdb({entry("/p/z.cpp", "-DZ"), entry("/p/a.cpp", "-DA")}); auto merged = merge_compile_commands( fresh, "[]", [](const std::filesystem::path&) { return true; }); - EXPECT_LT(merged.find("/p/a.cpp"), merged.find("/p/z.cpp")); -} + expect(lt(merged.find("/p/a.cpp"), merged.find("/p/z.cpp"))); +}}; -TEST(CompileCommandsWriter, RejectsNonArrayFreshJson) { +UTest rejects_non_array_fresh_json{"CompileCommandsWriter/RejectsNonArrayFreshJson", [] { TempDir temp; auto result = publish_compile_commands( temp.path / "compile_commands.json", R"({"file":"not-an-array"})", [](const std::filesystem::path&) { return true; }); - ASSERT_FALSE(result.has_value()); - EXPECT_NE(result.error().message.find("JSON array"), std::string::npos); -} + expect(fatal(!result.has_value())); + expect(ne(result.error().message.find("JSON array"), std::string::npos)); +}}; -TEST(CompileCommandsWriter, PublishesFreshDatabaseWhenNoneExists) { +UTest publishes_fresh_database_when_none_exists{"CompileCommandsWriter/PublishesFreshDatabaseWhenNoneExists", [] { TempDir temp; auto path = temp.path / "compile_commands.json"; // deliberately absent auto content = cdb({entry("/p/src/main.cpp", "-O2")}); @@ -183,16 +185,16 @@ TEST(CompileCommandsWriter, PublishesFreshDatabaseWhenNoneExists) { auto result = publish_compile_commands( path, content, [](const std::filesystem::path&) { return true; }); - ASSERT_TRUE(result.has_value()) << result.error().message; - EXPECT_TRUE(result->changed); + if (auto&& failed = expect(fatal(result.has_value())); !failed) failed << result.error().message; + expect(bool(result->changed)); // The file is re-serialised via nlohmann (sorted + 2-space indent), so // assert on content, not on byte-equality with the raw input. auto published = read_file(path); - EXPECT_NE(published.find("/p/src/main.cpp"), std::string::npos) << published; - EXPECT_NE(published.find("-O2"), std::string::npos) << published; -} + expect(ne(published.find("/p/src/main.cpp"), std::string::npos)) << published; + expect(ne(published.find("-O2"), std::string::npos)) << published; +}}; -TEST(CompileCommandsWriter, UnchangedContentKeepsMtime) { +UTest unchanged_content_keeps_mtime{"CompileCommandsWriter/UnchangedContentKeepsMtime", [] { TempDir temp; auto path = temp.path / "compile_commands.json"; auto content = cdb({entry((temp.path / "a.cpp").string(), "-DOK")}); @@ -202,12 +204,12 @@ TEST(CompileCommandsWriter, UnchangedContentKeepsMtime) { auto result = publish_compile_commands( path, content, [](const std::filesystem::path&) { return true; }); - ASSERT_TRUE(result.has_value()) << result.error().message; - EXPECT_FALSE(result->changed); - EXPECT_EQ(std::filesystem::last_write_time(path), before); -} + if (auto&& failed = expect(fatal(result.has_value())); !failed) failed << result.error().message; + expect(!result->changed); + expect(eq(std::filesystem::last_write_time(path), before)); +}}; -TEST(CompileCommandsWriter, ReplacementFailurePreservesOldDatabase) { +UTest replacement_failure_preserves_old_database{"CompileCommandsWriter/ReplacementFailurePreservesOldDatabase", [] { TempDir temp; auto path = temp.path / "compile_commands.json"; auto oldContent = cdb({entry((temp.path / "old.cpp").string(), "-DOLD")}); @@ -224,11 +226,11 @@ TEST(CompileCommandsWriter, ReplacementFailurePreservesOldDatabase) { path, newContent, [](const std::filesystem::path&) { return true; }, failReplace); - ASSERT_FALSE(result.has_value()); - EXPECT_EQ(read_file(path), oldContent); - EXPECT_EQ(std::distance(std::filesystem::directory_iterator(temp.path), - std::filesystem::directory_iterator{}), 1); -} + expect(fatal(!result.has_value())); + expect(eq(read_file(path), oldContent)); + expect(eq(std::distance(std::filesystem::directory_iterator(temp.path), + std::filesystem::directory_iterator{}), 1)); +}}; // #390 self-heal: a CDB written BEFORE the mixed-separator fix spells the same // file differently from the way a fresh plan spells it. Because the merge key @@ -241,10 +243,10 @@ TEST(CompileCommandsWriter, ReplacementFailurePreservesOldDatabase) { // Windows the `/` separators need make_preferred on top. A stale value of just // `p.generic_string()` would be byte-identical to fresh on POSIX, and the // literal-string key this test exists to replace would pass it unchanged. -TEST(CompileCommandsMerge, NormalizedFileKeysHealStaleSeparatorSpellings) { +UTest normalized_file_keys_heal_stale_separator_spellings{"CompileCommandsMerge/NormalizedFileKeysHealStaleSeparatorSpellings", [] { auto p = std::filesystem::path("/p") / "generated" / "modules" / "a.cpp"; auto stale = "/p/generated/./modules/a.cpp"; - ASSERT_NE(p.string(), stale) << "fixture must differ from the fresh spelling"; + expect(fatal(ne(p.string(), stale))) << "fixture must differ from the fresh spelling"; auto fresh = cdb({ entry(p.string(), "-O2-FRESH") }); auto existing = cdb({ entry(stale, "-O0-STALE") }); @@ -252,10 +254,10 @@ TEST(CompileCommandsMerge, NormalizedFileKeysHealStaleSeparatorSpellings) { auto merged = merge_compile_commands( fresh, existing, [](const std::filesystem::path&) { return true; }); - EXPECT_EQ(count(merged, "generated"), 1u) << merged; - EXPECT_NE(merged.find("-O2-FRESH"), std::string::npos) << merged; - EXPECT_EQ(merged.find("-O0-STALE"), std::string::npos) << merged; -} + expect(eq(count(merged, "generated"), 1u)) << merged; + expect(ne(merged.find("-O2-FRESH"), std::string::npos)) << merged; + expect(eq(merged.find("-O0-STALE"), std::string::npos)) << merged; +}}; // ── CDB arguments must be argv, not shell words ───────────────────────────── // @@ -294,63 +296,63 @@ bool has_edge_quote(std::string_view s) { // The host's own reader: single quotes are quoting for `sh` and literal // characters for the MSVCRT rules, so this row holds on POSIX hosts only. -TEST(CompileCommandsArgs, StripsPosixQuotingFromAToken) { +UTest strips_posix_quoting_from_a_token{"CompileCommandsArgs/StripsPosixQuotingFromAToken", [] { if constexpr (mcpplibs::os::is_windows) { - GTEST_SKIP() << "single quotes are literal under the MSVCRT rules"; + skip("single quotes are literal under the MSVCRT rules"); } auto out = mcpp::build::split_flags("-O2 '-fprebuilt-module-path=/a/b' -g"); - ASSERT_EQ(out.size(), 3u); - EXPECT_EQ(out[1], "-fprebuilt-module-path=/a/b"); - for (auto const& t : out) EXPECT_FALSE(has_edge_quote(t)) << t; -} + expect(fatal(eq(out.size(), 3u))); + expect(eq(out[1], "-fprebuilt-module-path=/a/b")); + for (auto const& t : out) expect(!has_edge_quote(t)) << t; +}}; -TEST(CompileCommandsArgs, StripsWindowsQuotingFromAToken) { +UTest strips_windows_quoting_from_a_token{"CompileCommandsArgs/StripsWindowsQuotingFromAToken", [] { auto out = mcpp::build::split_flags(R"(-O2 "-IC:\Users\x\inc" -g)"); - ASSERT_EQ(out.size(), 3u); - EXPECT_EQ(out[1], R"(-IC:\Users\x\inc)"); - for (auto const& t : out) EXPECT_FALSE(has_edge_quote(t)) << t; -} + expect(fatal(eq(out.size(), 3u))); + expect(eq(out[1], R"(-IC:\Users\x\inc)")); + for (auto const& t : out) expect(!has_edge_quote(t)) << t; +}}; // The one that bit: a quoted path containing a space is ONE argument. The // space arrives ninja-escaped (`$ `) because flags.cppm escapes before it // quotes, so the un-escape has to happen INSIDE the quotes — do it in the // wrong order and the token splits exactly here. -TEST(CompileCommandsArgs, AQuotedPathWithASpaceStaysOneToken) { +UTest a_quoted_path_with_a_space_stays_one_token{"CompileCommandsArgs/AQuotedPathWithASpaceStaysOneToken", [] { // The quotes are the host's: flags.cppm quotes with `'` on POSIX and `"` // under the MSVCRT rules. const std::string_view line = mcpplibs::os::is_windows ? "-O2 \"-fmodule-file=std=/tmp/my$ project/std.pcm\" -g" : "-O2 '-fmodule-file=std=/tmp/my$ project/std.pcm' -g"; auto out = mcpp::build::split_flags(line); - ASSERT_EQ(out.size(), 3u) << "the quoted path was split"; - EXPECT_EQ(out[1], "-fmodule-file=std=/tmp/my project/std.pcm"); - for (auto const& t : out) EXPECT_FALSE(has_edge_quote(t)) << t; -} + expect(fatal(eq(out.size(), 3u))) << "the quoted path was split"; + expect(eq(out[1], "-fmodule-file=std=/tmp/my project/std.pcm")); + for (auto const& t : out) expect(!has_edge_quote(t)) << t; +}}; // Unquoted input: ninja's escapes are undone first, and then the host splits // the line. `$ ` is a plain space by the time the host reads it, so an // unquoted `-Idir$ with$ space` is three words to the compiler, and the // database says so. The engine quotes every word it writes (include_token, // ninja_command_word); the quoted spelling is the test above. -TEST(CompileCommandsArgs, UnquotedInputSplitsWhereTheHostSplits) { +UTest unquoted_input_splits_where_the_host_splits{"CompileCommandsArgs/UnquotedInputSplitsWhereTheHostSplits", [] { auto out = mcpp::build::split_flags("-IC$:/x -DA=1 -Idir$ with$ space"); - ASSERT_EQ(out.size(), 5u); - EXPECT_EQ(out[0], "-IC:/x"); - EXPECT_EQ(out[1], "-DA=1"); - EXPECT_EQ(out[2], "-Idir"); - EXPECT_EQ(out[3], "with"); - EXPECT_EQ(out[4], "space"); -} + expect(fatal(eq(out.size(), 5u))); + expect(eq(out[0], "-IC:/x")); + expect(eq(out[1], "-DA=1")); + expect(eq(out[2], "-Idir")); + expect(eq(out[3], "with")); + expect(eq(out[4], "space")); +}}; // A quote inside a word is quoting on both hosts' readers: `sh` and the MSVCRT // rules both hand the compiler `-DGREETING=hi` for this text. The version of // this test before #655 asserted the opposite, which pinned the compile // database to a reading no host applies. -TEST(CompileCommandsArgs, QuotesInsideAWordAreQuoting) { +UTest quotes_inside_a_word_are_quoting{"CompileCommandsArgs/QuotesInsideAWordAreQuoting", [] { auto out = mcpp::build::split_flags(R"(-DGREETING="hi")"); - ASSERT_EQ(out.size(), 1u); - EXPECT_EQ(out[0], "-DGREETING=hi"); -} + expect(fatal(eq(out.size(), 1u))); + expect(eq(out[0], "-DGREETING=hi")); +}}; // ── A unit's flag list: the edge and the databases agree (#655) ───────────── // @@ -395,31 +397,28 @@ std::vector random_words() { } // namespace -TEST(CompileCommandsArgs, TheEdgeAndTheDatabaseListTheSameWords) { +UTest the_edge_and_the_database_list_the_same_words{"CompileCommandsArgs/TheEdgeAndTheDatabaseListTheSameWords", [] { const auto words = mcpp::manifest::flag_words(kIssue655Elements); - EXPECT_EQ(mcpp::build::split_flags(mcpp::build::ninja_flag_list(kIssue655Elements)), - words); + expect(eq(mcpp::build::split_flags(mcpp::build::ninja_flag_list(kIssue655Elements)), words)); const std::vector want{ R"(-DPLATFORM_CONFIG_H="mcpp_libarchive_config.h")", "-DMID=mid", "-DSQ=sq", "-include", "mcpp_lua_platform_config.h", "-I/opt/my dir/include", R"(-IC:\Users\x\include)", "-DDOLLAR=a$b", R"(-DDEF="def")", "-DSPACE=a b"}; - EXPECT_EQ(words, want); -} + expect(eq(words, want)); +}}; -TEST(CompileCommandsArgs, AWordOnAnEdgeReadsBackAsTheWord) { +UTest a_word_on_an_edge_reads_back_as_the_word{"CompileCommandsArgs/AWordOnAnEdgeReadsBackAsTheWord", [] { for (auto const& w : random_words()) { - EXPECT_EQ(mcpp::build::split_flags(mcpp::build::ninja_command_word(w)), - std::vector{w}) - << "word: [" << w << "]"; + if (auto&& failed = expect(eq(mcpp::build::split_flags(mcpp::build::ninja_command_word(w)), std::vector{w})); !failed) failed << "word: [" << w << "]"; } -} +}}; // The reader above is mcpp's model of the host. On a POSIX host the host itself // is available, so the model is checked against it: `/bin/sh` receives the // quoted words and prints each one back. -TEST(CompileCommandsArgs, ShellReceivesTheQuotedWordsAsWritten) { +UTest shell_receives_the_quoted_words_as_written{"CompileCommandsArgs/ShellReceivesTheQuotedWordsAsWritten", [] { if constexpr (mcpplibs::os::is_windows) { - GTEST_SKIP() << "the POSIX shell is the reader measured here"; + skip("the POSIX shell is the reader measured here"); } TempDir temp; const auto out = temp.path / "words.txt"; @@ -432,14 +431,14 @@ TEST(CompileCommandsArgs, ShellReceivesTheQuotedWordsAsWritten) { command += mcpp::build::shell_quote_arg(w); } command += " > '" + out.string() + "'"; - ASSERT_EQ(std::system(command.c_str()), 0); + expect(fatal(eq(std::system(command.c_str()), 0))); std::ifstream in(out); std::vector printed; for (std::string line; std::getline(in, line);) printed.push_back(line); - EXPECT_EQ(printed, words); -} + expect(eq(printed, words)); +}}; -TEST(CompileCommandsEmit, AUnitListsTheWordsOfItsFlagList) { +UTest a_unit_lists_the_words_of_its_flag_list{"CompileCommandsEmit/AUnitListsTheWordsOfItsFlagList", [] { BuildPlan plan; plan.projectRoot = "/p"; plan.outputDir = "/p/target"; @@ -457,8 +456,8 @@ TEST(CompileCommandsEmit, AUnitListsTheWordsOfItsFlagList) { for (auto const& a : j[0]["arguments"]) args.push_back(a.get()); const auto words = mcpp::manifest::flag_words(kIssue655Elements); auto at = std::search(args.begin(), args.end(), words.begin(), words.end()); - EXPECT_NE(at, args.end()) << "the unit's words are not listed in order"; -} + expect(ne(at, args.end())) << "the unit's words are not listed in order"; +}}; // ── Emitted paths use native separators ───────────────────────────────────── // @@ -472,7 +471,7 @@ TEST(CompileCommandsEmit, AUnitListsTheWordsOfItsFlagList) { // STRINGS — split_flags(f.cxx) and the package cflags/cxxflags — pass through // untouched and stay the ingestion points' responsibility; normalizing an // arbitrary flag payload is not safe (`-DPATH="/etc/x"` holds real slashes). -TEST(CompileCommandsEmit, EmittedPathsUseNativeSeparators) { +UTest emitted_paths_use_native_separators{"CompileCommandsEmit/EmittedPathsUseNativeSeparators", [] { BuildPlan plan; plan.projectRoot = "/p"; plan.outputDir = "/p/target"; @@ -508,20 +507,20 @@ TEST(CompileCommandsEmit, EmittedPathsUseNativeSeparators) { // C3 (design 2026-09-26 §3.3): `directory` is the OUTPUT directory the // compiler runs in, not `plan.projectRoot` — here `/p/target`. if constexpr (std::filesystem::path::preferred_separator == '\\') { - EXPECT_EQ(e["file"].get(), "C:\\Users\\x\\src\\main.cpp"); - EXPECT_EQ(e["directory"].get(), "\\p\\target"); - EXPECT_EQ(e["output"].get(), "\\p\\target\\obj\\main.o"); - EXPECT_EQ(inc, "-IC:\\proj\\generated\\inc"); - EXPECT_EQ(incAfter, "-idirafterC:\\proj\\third_party\\inc"); + expect(eq(e["file"].get(), "C:\\Users\\x\\src\\main.cpp")); + expect(eq(e["directory"].get(), "\\p\\target")); + expect(eq(e["output"].get(), "\\p\\target\\obj\\main.o")); + expect(eq(inc, "-IC:\\proj\\generated\\inc")); + expect(eq(incAfter, "-idirafterC:\\proj\\third_party\\inc")); } else { - EXPECT_EQ(e["file"].get(), "C:/Users/x/src/main.cpp"); - EXPECT_EQ(e["directory"].get(), "/p/target"); - EXPECT_EQ(inc, "-IC:/proj/generated/inc"); - EXPECT_EQ(incAfter, "-idirafterC:/proj/third_party/inc"); + expect(eq(e["file"].get(), "C:/Users/x/src/main.cpp")); + expect(eq(e["directory"].get(), "/p/target")); + expect(eq(inc, "-IC:/proj/generated/inc")); + expect(eq(incAfter, "-idirafterC:/proj/third_party/inc")); } -} +}}; -TEST(CompileCommandsWriter, ReplacesSymlinkTargetWithoutRemovingLink) { +UTest replaces_symlink_target_without_removing_link{"CompileCommandsWriter/ReplacesSymlinkTargetWithoutRemovingLink", [] { TempDir temp; auto target = temp.path / "build" / "compile_commands.json"; auto link = temp.path / "compile_commands.json"; @@ -531,19 +530,19 @@ TEST(CompileCommandsWriter, ReplacesSymlinkTargetWithoutRemovingLink) { std::ofstream(target) << oldContent; std::error_code symlinkEc; std::filesystem::create_symlink(target, link, symlinkEc); - if (symlinkEc) GTEST_SKIP() << "symlink unavailable: " << symlinkEc.message(); + if (symlinkEc) skip([&] { std::ostringstream text; text << "symlink unavailable: " << symlinkEc.message(); return text.str(); }()); auto result = publish_compile_commands( link, newContent, [](const std::filesystem::path&) { return false; }); - ASSERT_TRUE(result.has_value()) << result.error().message; - EXPECT_TRUE(std::filesystem::is_symlink(link)); + if (auto&& failed = expect(fatal(result.has_value())); !failed) failed << result.error().message; + expect(std::filesystem::is_symlink(link)); auto published = read_file(target); - EXPECT_NE(published.find("-DNEW"), std::string::npos); - EXPECT_EQ(published.find("-DOLD"), std::string::npos); -} + expect(ne(published.find("-DNEW"), std::string::npos)); + expect(eq(published.find("-DOLD"), std::string::npos)); +}}; -TEST(CompileCommandsWriter, ExistingUnreadableDatabaseIsNotOverwritten) { +UTest existing_unreadable_database_is_not_overwritten{"CompileCommandsWriter/ExistingUnreadableDatabaseIsNotOverwritten", [] { TempDir temp; auto path = temp.path / "compile_commands.json"; std::ofstream(path) << "last-known-good"; @@ -551,30 +550,30 @@ TEST(CompileCommandsWriter, ExistingUnreadableDatabaseIsNotOverwritten) { std::filesystem::permissions( path, std::filesystem::perms::owner_read, std::filesystem::perm_options::remove, permissionEc); - if (permissionEc) GTEST_SKIP() << "cannot change permissions: " << permissionEc.message(); + if (permissionEc) skip([&] { std::ostringstream text; text << "cannot change permissions: " << permissionEc.message(); return text.str(); }()); std::ifstream probe(path); if (probe) { std::filesystem::permissions(path, std::filesystem::perms::owner_all, std::filesystem::perm_options::replace, permissionEc); - GTEST_SKIP() << "test user can still read restricted file"; + skip("test user can still read restricted file"); } auto result = publish_compile_commands( path, cdb({entry((temp.path / "new.cpp").string(), "-DNEW")}), [](const std::filesystem::path&) { return true; }); - ASSERT_FALSE(result.has_value()); - EXPECT_NE(result.error().message.find("read existing"), std::string::npos); + expect(fatal(!result.has_value())); + expect(ne(result.error().message.find("read existing"), std::string::npos)); std::filesystem::permissions(path, std::filesystem::perms::owner_all, std::filesystem::perm_options::replace, permissionEc); -} +}}; // ── One record, two databases ─────────────────────────────────────────────── // // The compile database and the build database render the same per-unit record, // so `arguments` cannot differ between them. The second half shows the // comparison can fail: the record under other flags is not the CDB's. -TEST(CompileCommandsEmit, UnitInvocationsAreTheCompileDatabaseArguments) { +UTest unit_invocations_are_the_compile_database_arguments{"CompileCommandsEmit/UnitInvocationsAreTheCompileDatabaseArguments", [] { BuildPlan plan; plan.projectRoot = "/p"; plan.outputDir = "/p/target"; @@ -600,27 +599,26 @@ TEST(CompileCommandsEmit, UnitInvocationsAreTheCompileDatabaseArguments) { auto j = nlohmann::json::parse(emit_compile_commands(plan, flags)); auto invs = unit_invocations(plan, flags); - ASSERT_EQ(j.size(), invs.size()); + expect(fatal(eq(j.size(), invs.size()))); for (std::size_t i = 0; i < invs.size(); ++i) { - EXPECT_EQ(j[i]["arguments"].get>(), invs[i].arguments); - EXPECT_EQ(j[i]["file"].get(), invs[i].file); - EXPECT_EQ(j[i]["directory"].get(), invs[i].directory); - EXPECT_EQ(j[i]["output"].get(), invs[i].output); - EXPECT_EQ(invs[i].unit, &plan.compileUnits[i]); + expect(eq(j[i]["arguments"].get>(), invs[i].arguments)); + expect(eq(j[i]["file"].get(), invs[i].file)); + expect(eq(j[i]["directory"].get(), invs[i].directory)); + expect(eq(j[i]["output"].get(), invs[i].output)); + expect(eq(invs[i].unit, &plan.compileUnits[i])); } CompileFlags other = flags; other.cxx = "-std=c++26"; - EXPECT_NE(j[0]["arguments"].get>(), - unit_invocations(plan, other)[0].arguments); -} + expect(ne(j[0]["arguments"].get>(), unit_invocations(plan, other)[0].arguments)); +}}; // ── C3: `directory` is the output directory ───────────────────────────────── // // The JSON format and S1-8-2 define the field as the directory the compiler // runs in; ninja runs every compile in `plan.outputDir`, never the project // root (design 2026-09-26 §3.3). -TEST(CompileCommandsEmit, AProjectUnitsDirectoryIsTheOutputDirectory) { +UTest a_project_units_directory_is_the_output_directory{"CompileCommandsEmit/AProjectUnitsDirectoryIsTheOutputDirectory", [] { BuildPlan plan; plan.projectRoot = "/p"; plan.outputDir = "/p/target/x86_64-linux-gnu/deadbeef"; @@ -634,12 +632,12 @@ TEST(CompileCommandsEmit, AProjectUnitsDirectoryIsTheOutputDirectory) { flags.cxxBinary = "/usr/bin/g++"; auto invs = unit_invocations(plan, flags); - ASSERT_EQ(invs.size(), 1u); + expect(fatal(eq(invs.size(), 1u))); // Compared as paths: the field is written in the native spelling, which // on Windows turns this fixture's `/` into `\`. - EXPECT_EQ(std::filesystem::path(invs[0].directory), plan.outputDir); - EXPECT_NE(std::filesystem::path(invs[0].directory), plan.projectRoot); -} + expect(eq(std::filesystem::path(invs[0].directory), plan.outputDir)); + expect(ne(std::filesystem::path(invs[0].directory), plan.projectRoot)); +}}; // ── C4: the module interface language flag ────────────────────────────────── // @@ -647,7 +645,7 @@ TEST(CompileCommandsEmit, AProjectUnitsDirectoryIsTheOutputDirectory) { // (immediately before `-c`) for every unit that actually provides a module; // a unit that does not (an ordinary source, here) carries no such flag at // all (design 2026-09-26 §3.4). -TEST(CompileCommandsEmit, InterfaceUnitCarriesTheLanguageFlagBeforeDashCAndImplementationDoesNot) { +UTest interface_unit_carries_the_language_flag_before_dash_c_and_implementation_does_not{"CompileCommandsEmit/InterfaceUnitCarriesTheLanguageFlagBeforeDashCAndImplementationDoesNot", [] { BuildPlan plan; plan.projectRoot = "/p"; plan.outputDir = "/p/target"; @@ -669,24 +667,24 @@ TEST(CompileCommandsEmit, InterfaceUnitCarriesTheLanguageFlagBeforeDashCAndImple flags.cxxBinary = "/usr/bin/clang++"; auto invs = unit_invocations(plan, flags); - ASSERT_EQ(invs.size(), 2u); + expect(fatal(eq(invs.size(), 2u))); auto& iface = invs[0].arguments; auto ifaceC = std::ranges::find(iface, std::string("-c")); - ASSERT_NE(ifaceC, iface.end()); - ASSERT_GE(std::distance(iface.begin(), ifaceC), 2); - EXPECT_EQ(*(ifaceC - 2), "-x"); - EXPECT_EQ(*(ifaceC - 1), "c++-module"); + expect(fatal(ne(ifaceC, iface.end()))); + expect(fatal(ge(std::distance(iface.begin(), ifaceC), 2))); + expect(eq(*(ifaceC - 2), "-x")); + expect(eq(*(ifaceC - 1), "c++-module")); auto& impl = invs[1].arguments; - EXPECT_EQ(std::ranges::find(impl, std::string("-x")), impl.end()); -} + expect(eq(std::ranges::find(impl, std::string("-x")), impl.end())); +}}; // MSVC's form (`/interface /TP`) stays out of the record until a Windows // measurement of clang-cl-mode clangd answers whether it accepts `/interface` // (design §3.4, "Open"); until then an MSVC plan carries no language flag at // all, matching the implementation-unit case above rather than guessing. -TEST(CompileCommandsEmit, MsvcCarriesNoLanguageFlagYet) { +UTest msvc_carries_no_language_flag_yet{"CompileCommandsEmit/MsvcCarriesNoLanguageFlagYet", [] { BuildPlan plan; plan.projectRoot = "/p"; plan.outputDir = "/p/target"; @@ -702,7 +700,6 @@ TEST(CompileCommandsEmit, MsvcCarriesNoLanguageFlagYet) { flags.cxxBinary = "/vs/cl.exe"; auto invs = unit_invocations(plan, flags); - ASSERT_EQ(invs.size(), 1u); - EXPECT_EQ(std::ranges::find(invs[0].arguments, std::string("/interface")), - invs[0].arguments.end()); -} + expect(fatal(eq(invs.size(), 1u))); + expect(eq(std::ranges::find(invs[0].arguments, std::string("/interface")), invs[0].arguments.end())); +}}; diff --git a/tests/unit/test_compiler_transaction.cpp b/tests/unit/test_compiler_transaction.cpp index 222234015..414dd9347 100644 --- a/tests/unit/test_compiler_transaction.cpp +++ b/tests/unit/test_compiler_transaction.cpp @@ -1,5 +1,5 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.schedule.compiler_transaction; import mcpp.toolchain.fingerprint; import mcpp.run; @@ -8,6 +8,8 @@ import mcpp.runtime.elf; import mcpplibs.os.platform; import mcpplibs.os.fs; +using namespace mcpplibs::mtest; + namespace tx = mcpp::build::schedule::transaction; namespace fs = std::filesystem; namespace { @@ -28,16 +30,16 @@ struct Transaction { return *a; } void admitted(const tx::Attempt& a) { - ASSERT_TRUE(tx::publish(a.directory/"controller","mcpp-controller-v1",{"1","1",tx::spelling(generation/"actors/helper.lock")})); - ASSERT_TRUE(tx::publish(a.directory/"unit","mcpp-unit-v1",{"opaque-test-reference","1","1",tx::spelling(generation/"actors/helper.lock")})); - ASSERT_TRUE(tx::publish(a.directory/"reservation","mcpp-reservation-pointer-v1",{tx::spelling(reservation)})); - ASSERT_TRUE(tx::publish(reservation,"mcpp-reservation-v1",{tx::spelling(a.directory)})); - ASSERT_TRUE(tx::publish(a.directory/"admitted","mcpp-admitted-v1",{})); + expect(fatal(bool(tx::publish(a.directory/"controller","mcpp-controller-v1",{"1","1",tx::spelling(generation/"actors/helper.lock")})))); + expect(fatal(bool(tx::publish(a.directory/"unit","mcpp-unit-v1",{"opaque-test-reference","1","1",tx::spelling(generation/"actors/helper.lock")})))); + expect(fatal(bool(tx::publish(a.directory/"reservation","mcpp-reservation-pointer-v1",{tx::spelling(reservation)})))); + expect(fatal(bool(tx::publish(reservation,"mcpp-reservation-v1",{tx::spelling(a.directory)})))); + expect(fatal(bool(tx::publish(a.directory/"admitted","mcpp-admitted-v1",{})))); } void completed(const tx::Attempt& a,int rc=0) { admitted(a); std::ofstream(bmi)<<"new compiler BMI";std::ofstream(object)<<"complete object"; - ASSERT_TRUE(a.publication());ASSERT_TRUE(a.retired(rc)); + expect(fatal(bool(a.publication())));expect(fatal(bool(a.retired(rc)))); std::ofstream(tx::suffix(slot,".rc"))<::min(),-1LL,0LL,1LL,std::numeric_limits::max()}) { const auto stamp=tx::journal_time(fs::file_time_type{Duration{static_cast(value)}}); - ASSERT_TRUE(stamp);EXPECT_EQ(*stamp,value); - EXPECT_TRUE(tx::output_stamp("1",std::to_string(*stamp))); + expect(fatal(bool(stamp)));expect(eq(*stamp, value)); + expect(bool(tx::output_stamp("1",std::to_string(*stamp)))); } if constexpr(std::numeric_limits::digits>std::numeric_limits::digits) { auto above=static_cast(std::numeric_limits::max());++above; auto below=static_cast(std::numeric_limits::min());--below; - EXPECT_FALSE(tx::journal_time(fs::file_time_type{Duration{above}})); - EXPECT_FALSE(tx::journal_time(fs::file_time_type{Duration{below}})); + expect(!tx::journal_time(fs::file_time_type{Duration{above}})); + expect(!tx::journal_time(fs::file_time_type{Duration{below}})); } -} -TEST(CompilerTransaction, PreparedIdentityRoundTripsTheActualFileTimestamp) { +}}; +UTest prepared_identity_round_trips_the_actual_file_timestamp{"CompilerTransaction/PreparedIdentityRoundTripsTheActualFileTimestamp", [] { Transaction t;const auto prepared=t.prepare(); - const auto ticks=tx::journal_time(fs::last_write_time(t.bmi));ASSERT_TRUE(ticks); - EXPECT_EQ(prepared.before_time,std::to_string(*ticks)); - const auto loaded=tx::Attempt::load(prepared.directory);ASSERT_TRUE(loaded); - EXPECT_TRUE(loaded->before_present);EXPECT_EQ(loaded->before_time,prepared.before_time); - EXPECT_TRUE(tx::validate(*loaded)); -} -TEST(CompilerTransaction, UnidentifiableExistingBmiIsNotRecordedAsMissing) { + const auto ticks=tx::journal_time(fs::last_write_time(t.bmi));expect(fatal(bool(ticks))); + expect(eq(prepared.before_time, std::to_string(*ticks))); + const auto loaded=tx::Attempt::load(prepared.directory);expect(fatal(bool(loaded))); + expect(bool(loaded->before_present));expect(eq(loaded->before_time, prepared.before_time)); + expect(bool(tx::validate(*loaded))); +}}; +UTest unidentifiable_existing_bmi_is_not_recorded_as_missing{"CompilerTransaction/UnidentifiableExistingBmiIsNotRecordedAsMissing", [] { Transaction t;fs::create_directory(t.bmi); - EXPECT_FALSE(tx::Attempt::prepare(t.generation,false,t.bmi,t.object,t.slot,t.dep)); - EXPECT_TRUE(fs::is_directory(t.bmi));EXPECT_FALSE(fs::exists(t.generation)); -} -TEST(CompilerTransaction, RecordsAreBoundedChecksummedAndSchemaChecked) { + expect(!tx::Attempt::prepare(t.generation,false,t.bmi,t.object,t.slot,t.dep)); + expect(fs::is_directory(t.bmi));expect(!fs::exists(t.generation)); +}}; +UTest records_are_bounded_checksummed_and_schema_checked{"CompilerTransaction/RecordsAreBoundedChecksummedAndSchemaChecked", [] { Transaction t;const auto record=t.root/"record"; - ASSERT_TRUE(tx::publish(record,"schema",{"a\nb", "", "unicode \xc3\xa9"})); - auto good=tx::read(record,"schema");ASSERT_TRUE(good&&*good);EXPECT_EQ((**good)[0],"a\nb"); - EXPECT_FALSE(tx::read(record,"another-schema")); + expect(fatal(bool(tx::publish(record,"schema",{"a\nb", "", "unicode \xc3\xa9"})))); + auto good=tx::read(record,"schema");expect(fatal(good&&*good));expect(eq((**good)[0], "a\nb")); + expect(!tx::read(record,"another-schema")); auto bytes=Transaction::contents(record);bytes[0]='X';std::ofstream(record)<prepare_backup());fs::remove(t.root/"alias");fs::create_directory_symlink(t.root/"second",t.root/"alias"); + auto a=tx::Attempt::prepare(t.generation,false,path,t.object,t.slot,t.dep);expect(fatal(bool(a))); + expect(fatal(bool(a->prepare_backup())));fs::remove(t.root/"alias");fs::create_directory_symlink(t.root/"second",t.root/"alias"); std::ofstream(t.root/"first/unit.gcm")<<"changed";std::ofstream(t.root/"second/unit.gcm")<<"unrelated"; - ASSERT_TRUE(tx::recover(*a).value_or(false));EXPECT_EQ(Transaction::contents(t.root/"first/unit.gcm"),"original"); - EXPECT_EQ(Transaction::contents(t.root/"second/unit.gcm"),"unrelated"); -} + expect(fatal(bool(tx::recover(*a).value_or(false))));expect(eq(Transaction::contents(t.root/"first/unit.gcm"), "original")); + expect(eq(Transaction::contents(t.root/"second/unit.gcm"), "unrelated")); +}}; namespace { void configuration_outputs(Transaction& t,std::string_view name="unit") { @@ -169,145 +171,145 @@ void configuration_outputs(Transaction& t,std::string_view name="unit") { t.slot=tx::suffix(t.object,".sched"); } } -TEST(CompilerTransaction, MissingCertifiedObjectIsInvalidatedBeforeNinjaAdmission) { - Transaction t;configuration_outputs(t);auto a=t.prepare();ASSERT_TRUE(a.prepare_backup());t.completed(a); - ASSERT_TRUE(tx::recover(a).value_or(false));fs::remove(t.object); - ASSERT_TRUE(tx::admit_cached_outputs(t.root));EXPECT_FALSE(fs::exists(t.bmi)); - EXPECT_EQ(Transaction::contents(tx::suffix(t.bmi,".bak")),"new compiler BMI"); - EXPECT_FALSE(fs::exists(tx::suffix(t.slot,".completed"))); - EXPECT_TRUE(tx::admit_cached_outputs(t.root)); -} -TEST(CompilerTransaction, TwoMissingCertifiedObjectsAreInvalidatedInOneAdmission) { - Transaction t;configuration_outputs(t,"first");auto first=t.prepare();ASSERT_TRUE(first.prepare_backup());t.completed(first); - ASSERT_TRUE(tx::recover(first).value_or(false)); - configuration_outputs(t,"second");auto second=t.prepare();ASSERT_TRUE(second.prepare_backup());t.completed(second); - ASSERT_TRUE(tx::recover(second).value_or(false));fs::remove(first.object);fs::remove(second.object); - ASSERT_TRUE(tx::admit_cached_outputs(t.root));EXPECT_FALSE(fs::exists(first.bmi));EXPECT_FALSE(fs::exists(second.bmi)); -} -TEST(CompilerTransaction, CorruptOrForeignCertificateCannotAuthorizeAdmissionRecovery) { - Transaction t;configuration_outputs(t);auto a=t.prepare();ASSERT_TRUE(a.prepare_backup());t.completed(a); - ASSERT_TRUE(tx::recover(a).value_or(false));fs::remove(t.object); +UTest missing_certified_object_is_invalidated_before_ninja_admission{"CompilerTransaction/MissingCertifiedObjectIsInvalidatedBeforeNinjaAdmission", [] { + Transaction t;configuration_outputs(t);auto a=t.prepare();expect(fatal(bool(a.prepare_backup())));t.completed(a); + expect(fatal(bool(tx::recover(a).value_or(false))));fs::remove(t.object); + expect(fatal(bool(tx::admit_cached_outputs(t.root))));expect(!fs::exists(t.bmi)); + expect(eq(Transaction::contents(tx::suffix(t.bmi,".bak")), "new compiler BMI")); + expect(!(fs::exists(tx::suffix(t.slot,".completed")))); + expect(bool(tx::admit_cached_outputs(t.root))); +}}; +UTest two_missing_certified_objects_are_invalidated_in_one_admission{"CompilerTransaction/TwoMissingCertifiedObjectsAreInvalidatedInOneAdmission", [] { + Transaction t;configuration_outputs(t,"first");auto first=t.prepare();expect(fatal(bool(first.prepare_backup())));t.completed(first); + expect(fatal(bool(tx::recover(first).value_or(false)))); + configuration_outputs(t,"second");auto second=t.prepare();expect(fatal(bool(second.prepare_backup())));t.completed(second); + expect(fatal(bool(tx::recover(second).value_or(false))));fs::remove(first.object);fs::remove(second.object); + expect(fatal(bool(tx::admit_cached_outputs(t.root))));expect(!fs::exists(first.bmi));expect(!fs::exists(second.bmi)); +}}; +UTest corrupt_or_foreign_certificate_cannot_authorize_admission_recovery{"CompilerTransaction/CorruptOrForeignCertificateCannotAuthorizeAdmissionRecovery", [] { + Transaction t;configuration_outputs(t);auto a=t.prepare();expect(fatal(bool(a.prepare_backup())));t.completed(a); + expect(fatal(bool(tx::recover(a).value_or(false))));fs::remove(t.object); const auto path=tx::suffix(t.slot,".completed");auto bytes=Transaction::contents(path);bytes[0]='X';std::ofstream(path)<(held),std::istreambuf_iterator()),"old"); -} -TEST(CompilerTransaction, NoReplacePublishesOnlyAnAbsentRecord) { + expect(eq(Transaction::contents(record), replaced?"new":"old")); + expect(eq(std::string(std::istreambuf_iterator(held),std::istreambuf_iterator()), "old")); +}}; +UTest no_replace_publishes_only_an_absent_record{"CompilerTransaction/NoReplacePublishesOnlyAnAbsentRecord", [] { Transaction t; const auto source=t.root/"new"; const auto target=t.root/"status"; std::ofstream(source)<<"ready"; - ASSERT_TRUE(mcpplibs::os::fs::rename(source,target,false)); - EXPECT_EQ(Transaction::contents(target),"ready"); + expect(fatal(bool(mcpplibs::os::fs::rename(source,target,false)))); + expect(eq(Transaction::contents(target), "ready")); std::ofstream(source)<<"later"; - EXPECT_FALSE(mcpplibs::os::fs::rename(source,target,false)); - EXPECT_EQ(Transaction::contents(target),"ready"); -} -TEST(CompilerTransaction, RecordOpenAppearanceDriver) { - if(!mcpp::invocation::get("MCPP_TEST_RECORD_OPEN_DRIVER"))GTEST_SKIP()<<"subprocess driver"; + expect(!mcpplibs::os::fs::rename(source,target,false)); + expect(eq(Transaction::contents(target), "ready")); +}}; +UTest record_open_appearance_driver{"CompilerTransaction/RecordOpenAppearanceDriver", [] { + if(!mcpp::invocation::get("MCPP_TEST_RECORD_OPEN_DRIVER"))skip("subprocess driver"); auto record=tx::read("record","schema"); - EXPECT_TRUE(fs::exists("record-open-interposed")); - ASSERT_TRUE(record&&*record); - EXPECT_EQ((**record)[0],"published"); -} -TEST(CompilerTransaction, RecordAppearanceBetweenOpenAndExistsIsNotCorruption) { - if constexpr (!mcpplibs::os::is_linux)GTEST_SKIP()<<"Linux libc open interposition"; + expect(fs::exists("record-open-interposed")); + expect(fatal(record&&*record)); + expect(eq((**record)[0], "published")); +}}; +UTest record_appearance_between_open_and_exists_is_not_corruption{"CompilerTransaction/RecordAppearanceBetweenOpenAndExistsIsNotCorruption", [] { + if constexpr (!mcpplibs::os::is_linux)skip("Linux libc open interposition"); const auto runtime=mcpp::runtime::elf::inspect_elf_runtime(mcpp::invocation::self_exe_path()); if(!runtime || runtime->interp.find("ld-linux")==std::string::npos) - GTEST_SKIP()<<"open interposition requires the dynamic glibc loader"; + skip("open interposition requires the dynamic glibc loader"); Transaction t; const auto library=t.root/"record-open-appearance.so"; const auto source=fs::current_path()/"tests/e2e/helpers/record_open_appearance.c"; auto compiled=mcpp::run::execute({"/usr/bin/cc","-shared","-fPIC","-Wall","-Wextra","-Werror", source.string(),"-o",library.string(),"-ldl"},{.total=std::chrono::seconds(10)}); - ASSERT_EQ(compiled.exit_code,0)< refused_driver(const fs::path& root,std::string_view filter, @@ -316,10 +318,11 @@ std::optional refused_driver(const fs::path& root,std::str const auto source=fs::current_path()/"tests/e2e/helpers/record_open_refusal.c"; auto compiled=mcpp::run::execute({"/usr/bin/cc","-shared","-fPIC","-Wall","-Wextra","-Werror", source.string(),"-o",library.string(),"-ldl"},{.total=std::chrono::seconds(10)}); - if(compiled.exit_code!=0) {ADD_FAILURE()<exit_code,0)<output; + expect(fatal(bool(result))); + if (auto&& failed = expect(eq(result->exit_code, 0)); !failed) failed <output; // Four refusals: the reader before 2026.11.7.1 retried once and failed. - EXPECT_EQ(refusals(t.root/"record-refused"),4u); -} -TEST(CompilerTransaction, PersistentRefusalIsABoundedError) { - if(!preloadable())GTEST_SKIP()<<"open interposition requires Linux and the dynamic glibc loader"; + expect(eq(refusals(t.root/"record-refused"), 4u)); +}}; +UTest persistent_refusal_is_a_bounded_error{"CompilerTransaction/PersistentRefusalIsABoundedError", [] { + if(!preloadable())skip("open interposition requires Linux and the dynamic glibc loader"); Transaction t; - ASSERT_TRUE(tx::publish(t.root/"record","schema",{"published"})); + expect(fatal(bool(tx::publish(t.root/"record","schema",{"published"})))); const auto result=refused_driver(t.root,"RecordRefusalDriver", {{"MCPP_TEST_RECORD_REFUSAL_DRIVER","1"},{"MCPP_TEST_REFUSE_NAME","record"},{"MCPP_TEST_REFUSE_COUNT","-1"}}); - ASSERT_TRUE(result); - EXPECT_EQ(result->exit_code,0)<output; - EXPECT_GE(refusals(t.root/"record-refused"),2u); -} -TEST(CompilerTransaction, UnreadableControllerRecordIsNotAnEndedController) { - if(!preloadable())GTEST_SKIP()<<"open interposition requires Linux and the dynamic glibc loader"; + expect(fatal(bool(result))); + if (auto&& failed = expect(eq(result->exit_code, 0)); !failed) failed <output; + expect(ge(refusals(t.root/"record-refused"), 2u)); +}}; +UTest unreadable_controller_record_is_not_an_ended_controller{"CompilerTransaction/UnreadableControllerRecordIsNotAnEndedController", [] { + if(!preloadable())skip("open interposition requires Linux and the dynamic glibc loader"); Transaction t; auto a=t.prepare(); - ASSERT_TRUE(tx::publish(a.directory/"controller","mcpp-controller-v1",{"1","1",tx::spelling(t.generation/"actors/helper.lock")})); + expect(fatal(bool(tx::publish(a.directory/"controller","mcpp-controller-v1",{"1","1",tx::spelling(t.generation/"actors/helper.lock")})))); const auto result=refused_driver(t.root,"ControllerRefusalDriver", {{"MCPP_TEST_CONTROLLER_REFUSAL_DRIVER","1"},{"MCPP_TEST_ATTEMPT",a.directory.string()}, {"MCPP_TEST_REFUSE_NAME",(a.directory/"controller").string()}, {"MCPP_TEST_REFUSE_COUNT","-1"}}); - ASSERT_TRUE(result); - EXPECT_EQ(result->exit_code,0)<output; -} -TEST(CompilerTransaction, FailedCloseCannotPublishOrAcknowledgeARecord) { - if constexpr (!mcpplibs::os::is_linux)GTEST_SKIP()<<"Linux libc close failure control"; + expect(fatal(bool(result))); + if (auto&& failed = expect(eq(result->exit_code, 0)); !failed) failed <output; +}}; +UTest failed_close_cannot_publish_or_acknowledge_a_record{"CompilerTransaction/FailedCloseCannotPublishOrAcknowledgeARecord", [] { + if constexpr (!mcpplibs::os::is_linux)skip("Linux libc close failure control"); const auto runtime=mcpp::runtime::elf::inspect_elf_runtime(mcpp::invocation::self_exe_path()); if(!runtime || runtime->interp.find("ld-linux")==std::string::npos) - GTEST_SKIP()<<"close interposition requires the dynamic glibc loader"; + skip("close interposition requires the dynamic glibc loader"); Transaction t; const auto library=t.root/"record-close-failure.so"; const auto source=fs::current_path()/"tests/e2e/helpers/record_close_failure.c"; auto compiled=mcpp::run::execute({"/usr/bin/cc","-shared","-fPIC",source.string(), "-o",library.string(),"-ldl"},{.total=std::chrono::seconds(10)}); - ASSERT_EQ(compiled.exit_code,0)< #include #include #include import std; +import mcpplibs.mtest; import mcpp.config; import mcpp.fallback.config_migration; import mcpp.pm.index_spec; +using namespace mcpplibs::mtest; + namespace { std::filesystem::path make_tempdir(std::string_view name) { @@ -19,16 +21,16 @@ std::filesystem::path make_tempdir(std::string_view name) { } // namespace -TEST(Config, ProjectXlingsDataRootsIncludeLegacyAndNestedLayouts) { +UTest project_xlings_data_roots_include_legacy_and_nested_layouts{"Config/ProjectXlingsDataRootsIncludeLegacyAndNestedLayouts", [] { auto project = std::filesystem::path{"/tmp/mcpp-project"}; auto roots = mcpp::config::project_xlings_data_roots(project); - ASSERT_EQ(roots.size(), 2u); - EXPECT_EQ(roots[0], project / ".mcpp" / "data"); - EXPECT_EQ(roots[1], project / ".mcpp" / ".xlings" / "data"); -} + expect(fatal(eq(roots.size(), 2u))); + expect(eq(roots[0], project / ".mcpp" / "data")); + expect(eq(roots[1], project / ".mcpp" / ".xlings" / "data")); +}}; -TEST(Config, ProjectIndexDataInitializedChecksNestedXlingsData) { +UTest project_index_data_initialized_checks_nested_xlings_data{"Config/ProjectIndexDataInitializedChecksNestedXlingsData", [] { auto project = make_tempdir("mcpp-config-index-state"); auto nestedIndexDir = project / ".mcpp" / ".xlings" / "data" / "xim-index-repos"; std::filesystem::create_directories(nestedIndexDir); @@ -37,22 +39,21 @@ TEST(Config, ProjectIndexDataInitializedChecksNestedXlingsData) { os << "{}"; } - EXPECT_TRUE(mcpp::config::project_index_data_initialized(project)); + expect(bool(mcpp::config::project_index_data_initialized(project))); std::filesystem::remove_all(project); -} +}}; -TEST(Config, ResolveProjectIndexPathUsesProjectRootForRelativeLocalIndex) { +UTest resolve_project_index_path_uses_project_root_for_relative_local_index{"Config/ResolveProjectIndexPathUsesProjectRootForRelativeLocalIndex", [] { auto project = std::filesystem::path{"/tmp/mcpp-project"}; mcpp::pm::IndexSpec spec; spec.name = "xlings"; spec.path = "mcpp"; - EXPECT_EQ(mcpp::config::resolve_project_index_path(project, spec), - (project / "mcpp").lexically_normal()); -} + expect(eq(mcpp::config::resolve_project_index_path(project, spec), (project / "mcpp").lexically_normal())); +}}; -TEST(Config, ProjectIndexJsonEscapesLocalIndexPath) { +UTest project_index_json_escapes_local_index_path{"Config/ProjectIndexJsonEscapesLocalIndexPath", [] { auto project = make_tempdir("mcpp-config-json-escape"); auto index = project / "local" / "index"; std::filesystem::create_directories(index / "pkgs"); @@ -70,24 +71,24 @@ TEST(Config, ProjectIndexJsonEscapesLocalIndexPath) { indices.emplace(remote.name, remote); mcpp::config::GlobalConfig cfg; - ASSERT_TRUE(mcpp::config::ensure_project_index_dir(cfg, project, indices)); + expect(fatal(bool(mcpp::config::ensure_project_index_dir(cfg, project, indices)))); std::ifstream is(project / ".mcpp" / ".xlings.json"); - ASSERT_TRUE(is); + expect(fatal(bool(is))); std::stringstream ss; ss << is.rdbuf(); auto content = ss.str(); - EXPECT_NE(content.find(R"("name": "local\"dev")"), std::string::npos) + expect(ne(content.find(R"("name": "local\"dev")"), std::string::npos)) << content; - EXPECT_NE(content.find(index.generic_string()), std::string::npos) + expect(ne(content.find(index.generic_string()), std::string::npos)) << content; - EXPECT_NE(content.find(R"(https://example.com/a\\b\"c)"), std::string::npos) + expect(ne(content.find(R"(https://example.com/a\\b\"c)"), std::string::npos)) << content; is.close(); std::filesystem::remove_all(project); -} +}}; // The official global `xim` index must NOT be injected/exposed into the project // scope. `xim` (and its dynamic sub-indexes) are xlings GLOBAL-default indices — @@ -96,7 +97,7 @@ TEST(Config, ProjectIndexJsonEscapesLocalIndexPath) { // store instead of the shared registry, breaking ELF loader resolution for // build-dep tools. `xim:*` resolves at global scope via the registry-local clone. // See .agents/docs/2026-07-09-project-index-scope-global-infra-fix.md. -TEST(Config, ProjectIndexDirDoesNotInjectOfficialXimIndex) { +UTest project_index_dir_does_not_inject_official_xim_index{"Config/ProjectIndexDirDoesNotInjectOfficialXimIndex", [] { auto project = make_tempdir("mcpp-config-project-xim-index"); auto registry = make_tempdir("mcpp-config-registry"); auto official = registry / "data" / "xim-pkgindex"; @@ -118,30 +119,30 @@ TEST(Config, ProjectIndexDirDoesNotInjectOfficialXimIndex) { mcpp::config::GlobalConfig cfg; cfg.registryDir = registry; - ASSERT_TRUE(mcpp::config::ensure_project_index_dir(cfg, project, indices)); + expect(fatal(bool(mcpp::config::ensure_project_index_dir(cfg, project, indices)))); // xim is NOT copied/exposed into the project data dir (it stays global). auto projectOfficial = project / ".mcpp" / ".xlings" / "data" / "xim-pkgindex"; - EXPECT_FALSE(std::filesystem::exists(projectOfficial / "pkgs" / "p" / "python.lua")); + expect(!std::filesystem::exists(projectOfficial / "pkgs" / "p" / "python.lua")); // The user-declared local `compat` index IS exposed project-locally. auto projectLocal = project / ".mcpp" / ".xlings" / "data" / "compat"; - EXPECT_TRUE(std::filesystem::exists(projectLocal / "pkgs")); + expect(std::filesystem::exists(projectLocal / "pkgs")); // The seeded project .xlings.json index_repos must not contain `xim`. auto projJson = project / ".mcpp" / ".xlings.json"; if (std::filesystem::exists(projJson)) { std::ifstream is(projJson); std::stringstream ss; ss << is.rdbuf(); - EXPECT_EQ(ss.str().find("\"xim\""), std::string::npos); + expect(eq(ss.str().find("\"xim\""), std::string::npos)); } std::filesystem::remove_all(project); std::filesystem::remove_all(registry); -} +}}; -TEST(Config, ProjectLocalIndexStaleCacheIsRemoved) { +UTest project_local_index_stale_cache_is_removed{"Config/ProjectLocalIndexStaleCacheIsRemoved", [] { auto project = make_tempdir("mcpp-config-local-index-cache"); auto localIndex = project / "compat"; std::filesystem::create_directories(localIndex / "pkgs" / "c"); @@ -158,11 +159,11 @@ TEST(Config, ProjectLocalIndexStaleCacheIsRemoved) { indices.emplace(local.name, local); mcpp::config::GlobalConfig cfg; - ASSERT_TRUE(mcpp::config::ensure_project_index_dir(cfg, project, indices)); - EXPECT_FALSE(std::filesystem::exists(localIndex / ".xlings-index-cache.json")); + expect(fatal(bool(mcpp::config::ensure_project_index_dir(cfg, project, indices)))); + expect(!std::filesystem::exists(localIndex / ".xlings-index-cache.json")); std::filesystem::remove_all(project); -} +}}; // ── #267-A/#269: index org migration + artifact adoption ──────────────────── @@ -176,38 +177,38 @@ std::string read_all(const std::filesystem::path& p) { } // namespace -TEST(ConfigIndexMigration, CanonicalizeRewritesOrgAndName) { +UTest canonicalize_rewrites_org_and_name{"ConfigIndexMigration/CanonicalizeRewritesOrgAndName", [] { mcpp::config::GlobalConfig cfg; cfg.defaultIndex = "mcpp-index"; cfg.indexRepos.push_back({ "mcpp-index", std::string(mcpp::config::kMcpplibsIndexUrlLegacy) }); mcpp::config::canonicalize_legacy_index_names(cfg); - ASSERT_EQ(cfg.indexRepos.size(), 1u); - EXPECT_EQ(cfg.defaultIndex, "mcpplibs"); - EXPECT_EQ(cfg.indexRepos[0].name, "mcpplibs"); - EXPECT_EQ(cfg.indexRepos[0].url, mcpp::config::kMcpplibsIndexUrl); -} + expect(fatal(eq(cfg.indexRepos.size(), 1u))); + expect(eq(cfg.defaultIndex, "mcpplibs")); + expect(eq(cfg.indexRepos[0].name, "mcpplibs")); + expect(eq(cfg.indexRepos[0].url, mcpp::config::kMcpplibsIndexUrl)); +}}; -TEST(ConfigIndexMigration, CanonicalizeFoldsLegacyAndDefaultEntries) { +UTest canonicalize_folds_legacy_and_default_entries{"ConfigIndexMigration/CanonicalizeFoldsLegacyAndDefaultEntries", [] { mcpp::config::GlobalConfig cfg; cfg.indexRepos.push_back({ "mcpplibs", std::string(mcpp::config::kMcpplibsIndexUrlLegacy) }); cfg.indexRepos.push_back({ "mcpplibs", std::string(mcpp::config::kMcpplibsIndexUrl) }); mcpp::config::canonicalize_legacy_index_names(cfg); - EXPECT_EQ(cfg.indexRepos.size(), 1u); -} + expect(eq(cfg.indexRepos.size(), 1u)); +}}; -TEST(ConfigIndexMigration, CanonicalizeLeavesForeignReposAlone) { +UTest canonicalize_leaves_foreign_repos_alone{"ConfigIndexMigration/CanonicalizeLeavesForeignReposAlone", [] { mcpp::config::GlobalConfig cfg; cfg.indexRepos.push_back({ "other", "https://example.com/other-index.git" }); mcpp::config::canonicalize_legacy_index_names(cfg); - ASSERT_EQ(cfg.indexRepos.size(), 1u); - EXPECT_EQ(cfg.indexRepos[0].name, "other"); - EXPECT_EQ(cfg.indexRepos[0].url, "https://example.com/other-index.git"); -} + expect(fatal(eq(cfg.indexRepos.size(), 1u))); + expect(eq(cfg.indexRepos[0].name, "other")); + expect(eq(cfg.indexRepos[0].url, "https://example.com/other-index.git")); +}}; -TEST(ConfigIndexMigration, MigrateConfigTomlRewritesOrgUrlIdempotently) { +UTest migrate_config_toml_rewrites_org_url_idempotently{"ConfigIndexMigration/MigrateConfigTomlRewritesOrgUrlIdempotently", [] { auto dir = make_tempdir("mcpp-migrate-toml"); auto p = dir / "config.toml"; { @@ -215,25 +216,25 @@ TEST(ConfigIndexMigration, MigrateConfigTomlRewritesOrgUrlIdempotently) { os << "[index]\ndefault = \"mcpplibs\"\n\n[index.repos.\"mcpplibs\"]\n" "url = \"https://github.com/mcpp-community/mcpp-index.git\"\n"; } - EXPECT_TRUE(mcpp::fallback::migrate_config_toml_index_names(p)); + expect(bool(mcpp::fallback::migrate_config_toml_index_names(p))); auto text = read_all(p); - EXPECT_NE(text.find("github.com/mcpplibs/mcpp-index.git"), std::string::npos); - EXPECT_EQ(text.find("mcpp-community/mcpp-index"), std::string::npos); - EXPECT_FALSE(mcpp::fallback::migrate_config_toml_index_names(p)); // idempotent - EXPECT_EQ(read_all(p), text); + expect(ne(text.find("github.com/mcpplibs/mcpp-index.git"), std::string::npos)); + expect(eq(text.find("mcpp-community/mcpp-index"), std::string::npos)); + expect(!mcpp::fallback::migrate_config_toml_index_names(p)); // idempotent + expect(eq(read_all(p), text)); std::filesystem::remove_all(dir); -} +}}; -TEST(ConfigIndexMigration, CanonicalizeFillsDefaultArtifactForOfficialRepo) { +UTest canonicalize_fills_default_artifact_for_official_repo{"ConfigIndexMigration/CanonicalizeFillsDefaultArtifactForOfficialRepo", [] { mcpp::config::GlobalConfig cfg; cfg.indexRepos.push_back({ "mcpplibs", std::string(mcpp::config::kMcpplibsIndexUrlLegacy) }); mcpp::config::canonicalize_legacy_index_names(cfg); - ASSERT_EQ(cfg.indexRepos.size(), 1u); - EXPECT_EQ(cfg.indexRepos[0].artifact, mcpp::config::kMcpplibsIndexArtifact); -} + expect(fatal(eq(cfg.indexRepos.size(), 1u))); + expect(eq(cfg.indexRepos[0].artifact, mcpp::config::kMcpplibsIndexArtifact)); +}}; -TEST(ConfigIndexMigration, CanonicalizeRespectsExplicitGitSourceAndCustomArtifact) { +UTest canonicalize_respects_explicit_git_source_and_custom_artifact{"ConfigIndexMigration/CanonicalizeRespectsExplicitGitSourceAndCustomArtifact", [] { mcpp::config::GlobalConfig cfg; mcpp::config::IndexRepo git{ "mcpplibs", std::string(mcpp::config::kMcpplibsIndexUrl) }; @@ -245,13 +246,13 @@ TEST(ConfigIndexMigration, CanonicalizeRespectsExplicitGitSourceAndCustomArtifac cfg.indexRepos.push_back(custom); cfg.indexRepos.push_back({ "other", "https://example.com/other-index.git" }); mcpp::config::canonicalize_legacy_index_names(cfg); - ASSERT_EQ(cfg.indexRepos.size(), 3u); - EXPECT_TRUE(cfg.indexRepos[0].artifact.empty()); // explicit git opt-out - EXPECT_EQ(cfg.indexRepos[1].artifact, "https://example.com/my-mirror"); - EXPECT_TRUE(cfg.indexRepos[2].artifact.empty()); // foreign repo untouched -} + expect(fatal(eq(cfg.indexRepos.size(), 3u))); + expect(cfg.indexRepos[0].artifact.empty()); // explicit git opt-out + expect(eq(cfg.indexRepos[1].artifact, "https://example.com/my-mirror")); + expect(cfg.indexRepos[2].artifact.empty()); // foreign repo untouched +}}; -TEST(ConfigIndexMigration, XlingsJsonHealsPrettyLegacyEntry) { +UTest xlings_json_heals_pretty_legacy_entry{"ConfigIndexMigration/XlingsJsonHealsPrettyLegacyEntry", [] { auto dir = make_tempdir("mcpp-migrate-xj"); auto p = dir / ".xlings.json"; { @@ -261,21 +262,20 @@ TEST(ConfigIndexMigration, XlingsJsonHealsPrettyLegacyEntry) { "\"https://github.com/mcpp-community/mcpp-index.git\" }\n" " ],\n \"subos\": \"default\",\n \"mirror\": \"auto\"\n}\n"; } - EXPECT_TRUE(mcpp::fallback::migrate_xlings_json_index_names(p)); + expect(bool(mcpp::fallback::migrate_xlings_json_index_names(p))); auto text = read_all(p); - EXPECT_NE(text.find( + expect(ne(text.find( "\"url\": \"https://github.com/mcpplibs/mcpp-index.git\", " "\"artifact\": { \"GLOBAL\": \"https://github.com/xlings-res/mcpp-index\", " - "\"CN\": \"https://gitcode.com/xlings-res/mcpp-index\" }"), - std::string::npos) << text; - EXPECT_NE(text.find("\"subos\": \"default\""), std::string::npos); - EXPECT_EQ(text.find("mcpp-community/mcpp-index"), std::string::npos); - EXPECT_FALSE(mcpp::fallback::migrate_xlings_json_index_names(p)); // idempotent - EXPECT_EQ(read_all(p), text); + "\"CN\": \"https://gitcode.com/xlings-res/mcpp-index\" }"), std::string::npos)) << text; + expect(ne(text.find("\"subos\": \"default\""), std::string::npos)); + expect(eq(text.find("mcpp-community/mcpp-index"), std::string::npos)); + expect(!mcpp::fallback::migrate_xlings_json_index_names(p)); // idempotent + expect(eq(read_all(p), text)); std::filesystem::remove_all(dir); -} +}}; -TEST(ConfigIndexMigration, XlingsJsonHealsCompactLegacyNameAndUrl) { +UTest xlings_json_heals_compact_legacy_name_and_url{"ConfigIndexMigration/XlingsJsonHealsCompactLegacyNameAndUrl", [] { auto dir = make_tempdir("mcpp-migrate-xjc"); auto p = dir / ".xlings.json"; { @@ -284,21 +284,20 @@ TEST(ConfigIndexMigration, XlingsJsonHealsCompactLegacyNameAndUrl) { "\"url\":\"https://github.com/mcpp-community/mcpp-index.git\"}]," "\"mirror\":\"auto\"}"; } - EXPECT_TRUE(mcpp::fallback::migrate_xlings_json_index_names(p)); + expect(bool(mcpp::fallback::migrate_xlings_json_index_names(p))); auto text = read_all(p); - EXPECT_NE(text.find("\"name\":\"mcpplibs\""), std::string::npos); - EXPECT_NE(text.find( + expect(ne(text.find("\"name\":\"mcpplibs\""), std::string::npos)); + expect(ne(text.find( "\"url\":\"https://github.com/mcpplibs/mcpp-index.git\", " "\"artifact\": { \"GLOBAL\": \"https://github.com/xlings-res/mcpp-index\", " - "\"CN\": \"https://gitcode.com/xlings-res/mcpp-index\" }"), - std::string::npos) << text; - EXPECT_FALSE(mcpp::fallback::migrate_xlings_json_index_names(p)); + "\"CN\": \"https://gitcode.com/xlings-res/mcpp-index\" }"), std::string::npos)) << text; + expect(!mcpp::fallback::migrate_xlings_json_index_names(p)); std::filesystem::remove_all(dir); -} +}}; // #648 A6. A home seeded with the GitHub-only default artifact gains the CN half // of the region object once; the migration is idempotent afterwards. -TEST(ConfigIndexMigration, XlingsJsonUpgradesTheFlatDefaultArtifactToTheRegionObject) { +UTest xlings_json_upgrades_the_flat_default_artifact_to_the_region_object{"ConfigIndexMigration/XlingsJsonUpgradesTheFlatDefaultArtifactToTheRegionObject", [] { auto dir = make_tempdir("mcpp-migrate-xja"); auto p = dir / ".xlings.json"; { @@ -309,17 +308,17 @@ TEST(ConfigIndexMigration, XlingsJsonUpgradesTheFlatDefaultArtifactToTheRegionOb "\"artifact\": \"https://github.com/xlings-res/mcpp-index\" }\n" " ]\n}\n"; } - EXPECT_TRUE(mcpp::fallback::migrate_xlings_json_index_names(p)); + expect(bool(mcpp::fallback::migrate_xlings_json_index_names(p))); auto text = read_all(p); - EXPECT_NE(text.find("\"artifact\": { \"GLOBAL\": \"https://github.com/xlings-res/mcpp-index\", " - "\"CN\": \"https://gitcode.com/xlings-res/mcpp-index\" }"), std::string::npos) << text; - EXPECT_FALSE(mcpp::fallback::migrate_xlings_json_index_names(p)); - EXPECT_EQ(read_all(p), text); + expect(ne(text.find("\"artifact\": { \"GLOBAL\": \"https://github.com/xlings-res/mcpp-index\", " + "\"CN\": \"https://gitcode.com/xlings-res/mcpp-index\" }"), std::string::npos)) << text; + expect(!mcpp::fallback::migrate_xlings_json_index_names(p)); + expect(eq(read_all(p), text)); std::filesystem::remove_all(dir); -} +}}; // An artifact base the user wrote is theirs: no CN half is added to it. -TEST(ConfigIndexMigration, XlingsJsonLeavesAUserArtifactBaseAlone) { +UTest xlings_json_leaves_a_user_artifact_base_alone{"ConfigIndexMigration/XlingsJsonLeavesAUserArtifactBaseAlone", [] { auto dir = make_tempdir("mcpp-migrate-xju"); auto p = dir / ".xlings.json"; std::string body = @@ -332,7 +331,7 @@ TEST(ConfigIndexMigration, XlingsJsonLeavesAUserArtifactBaseAlone) { std::ofstream os(p); os << body; } - EXPECT_FALSE(mcpp::fallback::migrate_xlings_json_index_names(p)); - EXPECT_EQ(read_all(p), body); + expect(!mcpp::fallback::migrate_xlings_json_index_names(p)); + expect(eq(read_all(p), body)); std::filesystem::remove_all(dir); -} +}}; diff --git a/tests/unit/test_configure.cpp b/tests/unit/test_configure.cpp index 4c5df7777..4712ad93f 100644 --- a/tests/unit/test_configure.cpp +++ b/tests/unit/test_configure.cpp @@ -1,11 +1,13 @@ -#include import std; +import mcpplibs.mtest; import mcpp.source_kind; import mcpp.build.configure; import mcpp.build.plan; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + namespace { struct TempDir { @@ -27,7 +29,7 @@ void write_file(const std::filesystem::path& path, std::string_view text) { out << text; } -TEST(ConfigurePrerequisites, StagesOnlyBmisNeededByLanguageTools) { +UTest stages_only_bmis_needed_by_language_tools{"ConfigurePrerequisites/StagesOnlyBmisNeededByLanguageTools", [] { TempDir temp; mcpp::build::BuildPlan plan; plan.outputDir = temp.path / "out"; @@ -48,14 +50,14 @@ TEST(ConfigurePrerequisites, StagesOnlyBmisNeededByLanguageTools) { plan.compileUnits.push_back(dep); auto staged = mcpp::build::stage_configure_prerequisites(plan); - ASSERT_TRUE(staged.has_value()) << staged.error(); - EXPECT_TRUE(std::filesystem::exists(plan.outputDir / "pcm.cache/std.pcm")); - EXPECT_TRUE(std::filesystem::exists(plan.outputDir / "pcm.cache/std.compat.pcm")); - EXPECT_TRUE(std::filesystem::exists(plan.outputDir / "pcm.cache/demo.dep.pcm")); - EXPECT_FALSE(std::filesystem::exists(plan.outputDir / dep.object)); -} + if (auto&& failed = expect(fatal(staged.has_value())); !failed) failed << staged.error(); + expect(std::filesystem::exists(plan.outputDir / "pcm.cache/std.pcm")); + expect(std::filesystem::exists(plan.outputDir / "pcm.cache/std.compat.pcm")); + expect(std::filesystem::exists(plan.outputDir / "pcm.cache/demo.dep.pcm")); + expect(!std::filesystem::exists(plan.outputDir / dep.object)); +}}; -TEST(ConfigurePrerequisites, MissingCachedBmiFailsBeforePublication) { +UTest missing_cached_bmi_fails_before_publication{"ConfigurePrerequisites/MissingCachedBmiFailsBeforePublication", [] { TempDir temp; mcpp::build::BuildPlan plan; plan.outputDir = temp.path / "out"; @@ -68,8 +70,8 @@ TEST(ConfigurePrerequisites, MissingCachedBmiFailsBeforePublication) { plan.compileUnits.push_back(dep); auto staged = mcpp::build::stage_configure_prerequisites(plan); - ASSERT_FALSE(staged.has_value()); - EXPECT_NE(staged.error().find("demo.dep"), std::string::npos); -} + expect(fatal(!staged.has_value())); + expect(ne(staged.error().find("demo.dep"), std::string::npos)); +}}; } // namespace diff --git a/tests/unit/test_core_vendor_probes.cpp b/tests/unit/test_core_vendor_probes.cpp index 833b62438..a6f7e6ea6 100644 --- a/tests/unit/test_core_vendor_probes.cpp +++ b/tests/unit/test_core_vendor_probes.cpp @@ -1,6 +1,8 @@ -#include import std; +import mcpplibs.mtest; + +using namespace mcpplibs::mtest; // The engine owns no vendor probes. // @@ -54,10 +56,10 @@ std::string without_comments(std::string_view source) { } // namespace -TEST(CoreVendorProbes, TheEngineNamesNoVendorTool) { +UTest the_engine_names_no_vendor_tool{"CoreVendorProbes/TheEngineNamesNoVendorTool", [] { auto repo = std::filesystem::path(__FILE__).parent_path().parent_path().parent_path(); auto src = repo / "src"; - ASSERT_TRUE(std::filesystem::is_directory(src)) << src; + expect(fatal(std::filesystem::is_directory(src))) << src; // Tools, probes and locations. Each one is something only a rule package // has business invoking or reading. const std::vector tools = { @@ -86,11 +88,10 @@ TEST(CoreVendorProbes, TheEngineNamesNoVendorTool) { std::string lowered = tool; std::ranges::transform(lowered, lowered.begin(), [](unsigned char c) { return std::tolower(c); }); - EXPECT_EQ(lineText.find(lowered), std::string::npos) - << it->path() << ':' << line << " names a vendor tool: " << tool; + if (auto&& failed = expect(eq(lineText.find(lowered), std::string::npos)); !failed) failed << it->path() << ':' << line << " names a vendor tool: " << tool; } } } // The denominator: a scan that found no files would pass vacuously. - EXPECT_GT(files, 100u); -} + expect(gt(files, 100u)); +}}; diff --git a/tests/unit/test_cppfly.cpp b/tests/unit/test_cppfly.cpp index 45235068d..64fb0f621 100644 --- a/tests/unit/test_cppfly.cpp +++ b/tests/unit/test_cppfly.cpp @@ -3,12 +3,14 @@ // the c++latest/c++fly-aware std-flag spelling. // Table facts pinned by on-machine probes (gcc 16.1.0, 2026-07-14) — see // .agents/docs/2026-07-14-std-features-experimental-gate-design.md §2/§11-Q3. -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.cppfly; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + using namespace mcpp::toolchain; namespace { @@ -29,93 +31,93 @@ const cppfly::FeatureState* feature(const cppfly::Resolution& r, std::string_vie } } // namespace -TEST(CppFly, CompilerMajor) { - EXPECT_EQ(cppfly::compiler_major(tc_of(CompilerId::GCC, "16.1.0")), 16); - EXPECT_EQ(cppfly::compiler_major(tc_of(CompilerId::Clang, "22.1.8")), 22); - EXPECT_EQ(cppfly::compiler_major(tc_of(CompilerId::GCC, "")), 0); -} +UTest cpp_fly_compiler_major{"CppFly/CompilerMajor", [] { + expect(eq(cppfly::compiler_major(tc_of(CompilerId::GCC, "16.1.0")), 16)); + expect(eq(cppfly::compiler_major(tc_of(CompilerId::Clang, "22.1.8")), 22)); + expect(eq(cppfly::compiler_major(tc_of(CompilerId::GCC, "")), 0)); +}}; -TEST(CppFly, LatestStdCanonical) { +UTest cpp_fly_latest_std_canonical{"CppFly/LatestStdCanonical", [] { int lvl = 0; - EXPECT_EQ(cppfly::latest_std_canonical(tc_of(CompilerId::GCC, "16.1.0"), &lvl), "c++26"); - EXPECT_EQ(lvl, 26); - EXPECT_EQ(cppfly::latest_std_canonical(tc_of(CompilerId::GCC, "13.2.0")), "c++23"); - EXPECT_EQ(cppfly::latest_std_canonical(tc_of(CompilerId::Clang, "22.1.8")), "c++2c"); - EXPECT_EQ(cppfly::latest_std_canonical(tc_of(CompilerId::MSVC, "19.44")), "c++26"); -} + expect(eq(cppfly::latest_std_canonical(tc_of(CompilerId::GCC, "16.1.0"), &lvl), "c++26")); + expect(eq(lvl, 26)); + expect(eq(cppfly::latest_std_canonical(tc_of(CompilerId::GCC, "13.2.0")), "c++23")); + expect(eq(cppfly::latest_std_canonical(tc_of(CompilerId::Clang, "22.1.8")), "c++2c")); + expect(eq(cppfly::latest_std_canonical(tc_of(CompilerId::MSVC, "19.44")), "c++26")); +}}; -TEST(CppFly, ResolveGcc16) { +UTest resolve_gcc16{"CppFly/ResolveGcc16", [] { auto r = cppfly::resolve(tc_of(CompilerId::GCC, "16.1.0")); - EXPECT_EQ(r.stdCanonical, "c++26"); - EXPECT_TRUE(has_flag(r.flags, "-freflection")); + expect(eq(r.stdCanonical, "c++26")); + expect(has_flag(r.flags, "-freflection")); auto* refl = feature(r, "reflection"); - ASSERT_NE(refl, nullptr); - EXPECT_TRUE(refl->enabled); - EXPECT_EQ(refl->paper, "P2996"); + expect(fatal(ne(refl, nullptr))); + expect(bool(refl->enabled)); + expect(eq(refl->paper, "P2996")); // Contracts: enabled by -std=c++26 alone on GCC 16 (probe-pinned) — no // extra flag, but reported enabled for the summary. auto* con = feature(r, "contracts"); - ASSERT_NE(con, nullptr); - EXPECT_TRUE(con->enabled); - EXPECT_TRUE(con->flags.empty()); + expect(fatal(ne(con, nullptr))); + expect(bool(con->enabled)); + expect(con->flags.empty()); // libstdc++ ships experimental bits ungated. - EXPECT_FALSE(has_flag(r.flags, "-fexperimental-library")); -} + expect(!has_flag(r.flags, "-fexperimental-library")); +}}; -TEST(CppFly, ResolveGcc15SkipsByVersion) { +UTest resolve_gcc15_skips_by_version{"CppFly/ResolveGcc15SkipsByVersion", [] { auto r = cppfly::resolve(tc_of(CompilerId::GCC, "15.1.0")); - EXPECT_TRUE(r.flags.empty()); + expect(r.flags.empty()); auto* refl = feature(r, "reflection"); - ASSERT_NE(refl, nullptr); - EXPECT_FALSE(refl->enabled); - EXPECT_NE(refl->reason.find("16"), std::string::npos); // names the version floor -} + expect(fatal(ne(refl, nullptr))); + expect(!refl->enabled); + expect(ne(refl->reason.find("16"), std::string::npos)); // names the version floor +}}; -TEST(CppFly, ResolveClangLibcxx) { +UTest resolve_clang_libcxx{"CppFly/ResolveClangLibcxx", [] { auto r = cppfly::resolve(tc_of(CompilerId::Clang, "22.1.8", "libc++")); - EXPECT_EQ(r.stdCanonical, "c++2c"); - EXPECT_TRUE(has_flag(r.flags, "-fexperimental-library")); + expect(eq(r.stdCanonical, "c++2c")); + expect(has_flag(r.flags, "-fexperimental-library")); auto* refl = feature(r, "reflection"); - ASSERT_NE(refl, nullptr); - EXPECT_FALSE(refl->enabled); + expect(fatal(ne(refl, nullptr))); + expect(!refl->enabled); auto* lib = feature(r, "experimental-library"); - ASSERT_NE(lib, nullptr); - EXPECT_TRUE(lib->enabled); -} + expect(fatal(ne(lib, nullptr))); + expect(bool(lib->enabled)); +}}; -TEST(CppFly, ResolveMsvcAllSkipped) { +UTest resolve_msvc_all_skipped{"CppFly/ResolveMsvcAllSkipped", [] { auto r = cppfly::resolve(tc_of(CompilerId::MSVC, "19.44", "msvc-stl")); - EXPECT_TRUE(r.flags.empty()); - for (auto& f : r.features) EXPECT_FALSE(f.enabled); -} + expect(r.flags.empty()); + for (auto& f : r.features) expect(!f.enabled); +}}; -TEST(CppFly, StdFlagResolvesLatestAndFly) { +UTest std_flag_resolves_latest_and_fly{"CppFly/StdFlagResolvesLatestAndFly", [] { auto gcc16 = tc_of(CompilerId::GCC, "16.1.0"); - EXPECT_EQ(cppfly::std_flag(gcc16, "c++fly", 1000), "-std=c++26"); + expect(eq(cppfly::std_flag(gcc16, "c++fly", 1000), "-std=c++26")); // standard = "c++latest" used to reach the GNU driver as the invalid // spelling -std=c++latest (design §11-Q5) — now resolves to the family // latest. - EXPECT_EQ(cppfly::std_flag(gcc16, "c++latest", 999), "-std=c++26"); - EXPECT_EQ(cppfly::std_flag(gcc16, "c++23", 23), "-std=c++23"); - EXPECT_EQ(cppfly::std_flag(gcc16, "gnu++23", 23), "-std=gnu++23"); - EXPECT_EQ(cppfly::std_flag(tc_of(CompilerId::Clang, "22.1.8"), "c++fly", 1000), "-std=c++2c"); - EXPECT_EQ(cppfly::std_flag(tc_of(CompilerId::MSVC, "19.44"), "c++fly", 1000), "/std:c++latest"); - EXPECT_EQ(cppfly::std_flag(tc_of(CompilerId::MSVC, "19.44"), "c++latest", 999), "/std:c++latest"); + expect(eq(cppfly::std_flag(gcc16, "c++latest", 999), "-std=c++26")); + expect(eq(cppfly::std_flag(gcc16, "c++23", 23), "-std=c++23")); + expect(eq(cppfly::std_flag(gcc16, "gnu++23", 23), "-std=gnu++23")); + expect(eq(cppfly::std_flag(tc_of(CompilerId::Clang, "22.1.8"), "c++fly", 1000), "-std=c++2c")); + expect(eq(cppfly::std_flag(tc_of(CompilerId::MSVC, "19.44"), "c++fly", 1000), "/std:c++latest")); + expect(eq(cppfly::std_flag(tc_of(CompilerId::MSVC, "19.44"), "c++latest", 999), "/std:c++latest")); // A plain level is passed through untouched — c++20 must NOT be lifted to // the family latest by the >= 999 branch. - EXPECT_EQ(cppfly::std_flag(gcc16, "c++20", 20), "-std=c++20"); - EXPECT_EQ(cppfly::std_flag(gcc16, "gnu++20", 20), "-std=gnu++20"); - EXPECT_EQ(cppfly::std_flag(tc_of(CompilerId::Clang, "22.1.8"), "c++20", 20), "-std=c++20"); - EXPECT_EQ(cppfly::std_flag(tc_of(CompilerId::MSVC, "19.44"), "c++20", 20), "/std:c++20"); -} + expect(eq(cppfly::std_flag(gcc16, "c++20", 20), "-std=c++20")); + expect(eq(cppfly::std_flag(gcc16, "gnu++20", 20), "-std=gnu++20")); + expect(eq(cppfly::std_flag(tc_of(CompilerId::Clang, "22.1.8"), "c++20", 20), "-std=c++20")); + expect(eq(cppfly::std_flag(tc_of(CompilerId::MSVC, "19.44"), "c++20", 20), "/std:c++20")); +}}; -TEST(CppFly, EffectiveDialectFlagsDedup) { +UTest effective_dialect_flags_dedup{"CppFly/EffectiveDialectFlagsDedup", [] { auto gcc16 = tc_of(CompilerId::GCC, "16.1.0"); // User hand-wrote -freflection too — the fly gate must not duplicate it. auto out = cppfly::effective_dialect_flags(gcc16, true, {"-freflection", "-fchar8_t"}); - EXPECT_EQ(std::count(out.begin(), out.end(), std::string("-freflection")), 1); - EXPECT_TRUE(has_flag(out, "-fchar8_t")); + expect(eq(std::count(out.begin(), out.end(), std::string("-freflection")), 1)); + expect(has_flag(out, "-fchar8_t")); // Not experimental → manifest flags pass through untouched. auto off = cppfly::effective_dialect_flags(gcc16, false, {"-fchar8_t"}); - EXPECT_EQ(off, (std::vector{"-fchar8_t"})); -} + expect(eq(off, (std::vector{"-fchar8_t"}))); +}}; diff --git a/tests/unit/test_cxx_layer_standard.cpp b/tests/unit/test_cxx_layer_standard.cpp index 82df82945..cb940a240 100644 --- a/tests/unit/test_cxx_layer_standard.cpp +++ b/tests/unit/test_cxx_layer_standard.cpp @@ -9,9 +9,9 @@ // the plan are assertions on the compile edge, the scan edge and both build // databases. -#include import std; +import mcpplibs.mtest; import mcpp.build.plan; import mcpp.manifest; import mcpp.modgraph.graph; @@ -19,6 +19,8 @@ import mcpp.modgraph.scanner; import mcpp.source_kind; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + using namespace mcpp::build; namespace { @@ -130,7 +132,7 @@ std::map> plan_flags(const Fixture& f) { auto plan = make_plan(root, clangLike(), {}, graph, topo, packages, projectRoot, projectRoot / "target" / "t", {}, {}, {}); - EXPECT_TRUE(plan.has_value()) << (plan ? "" : plan.error()); + if (auto&& failed = expect(plan.has_value()); !failed) failed << (plan ? "" : plan.error()); std::map> out; if (!plan) return out; for (auto const& cu : plan->compileUnits) @@ -144,73 +146,73 @@ bool has(const std::vector& v, std::string_view flag) { } // namespace -TEST(CxxLayerStandard, ProviderImplementationUnitGetsTheStatedLevel) { +UTest provider_implementation_unit_gets_the_stated_level{"CxxLayerStandard/ProviderImplementationUnitGetsTheStatedLevel", [] { auto flags = plan_flags(Fixture{}); - ASSERT_TRUE(flags.contains("new.cpp")); - ASSERT_FALSE(flags["new.cpp"].empty()); - EXPECT_EQ(flags["new.cpp"].back(), "-std=c++23"); -} + expect(fatal(flags.contains("new.cpp"))); + expect(fatal(!(flags["new.cpp"].empty()))); + expect(eq(flags["new.cpp"].back(), "-std=c++23")); +}}; -TEST(CxxLayerStandard, UnitsThatTouchAModuleKeepTheGraphLevel) { +UTest units_that_touch_a_module_keep_the_graph_level{"CxxLayerStandard/UnitsThatTouchAModuleKeepTheGraphLevel", [] { auto flags = plan_flags(Fixture{}); - EXPECT_FALSE(has(flags["iface.cppm"], "-std=c++23")); // provides a module - EXPECT_FALSE(has(flags["importer.cpp"], "-std=c++23")); // imports std - EXPECT_FALSE(has(flags["iface_impl.cpp"], "-std=c++23")); // `module M;` - EXPECT_FALSE(has(flags["c_part.c"], "-std=c++23")); // not C++ -} + expect(!has(flags["iface.cppm"], "-std=c++23")); // provides a module + expect(!has(flags["importer.cpp"], "-std=c++23")); // imports std + expect(!has(flags["iface_impl.cpp"], "-std=c++23")); // `module M;` + expect(!has(flags["c_part.c"], "-std=c++23")); // not C++ +}}; -TEST(CxxLayerStandard, OtherPackagesKeepTheOneStandardRule) { +UTest other_packages_keep_the_one_standard_rule{"CxxLayerStandard/OtherPackagesKeepTheOneStandardRule", [] { auto flags = plan_flags(Fixture{}); - EXPECT_FALSE(has(flags["plain.cpp"], "-std=c++23")); - EXPECT_FALSE(has(flags["app.cpp"], "-std=c++23")); -} + expect(!has(flags["plain.cpp"], "-std=c++23")); + expect(!has(flags["app.cpp"], "-std=c++23")); +}}; -TEST(CxxLayerStandard, AProviderThatStatesNothingIsUnchanged) { +UTest a_provider_that_states_nothing_is_unchanged{"CxxLayerStandard/AProviderThatStatesNothingIsUnchanged", [] { Fixture f; f.providerStandard.clear(); auto flags = plan_flags(f); for (auto const& [file, v] : flags) for (auto const& flag : v) - EXPECT_FALSE(flag.starts_with("-std=")) << file << ": " << flag; -} + expect(!flag.starts_with("-std=")) << file << ": " << flag; +}}; -TEST(CxxLayerStandard, OnlyTheCurrentLayerSpellingIsEnough) { +UTest only_the_current_layer_spelling_is_enough{"CxxLayerStandard/OnlyTheCurrentLayerSpellingIsEnough", [] { Fixture f; f.providerProvides = {"mcpp:c++-abi=libc++"}; auto flags = plan_flags(f); - ASSERT_FALSE(flags["new.cpp"].empty()); - EXPECT_EQ(flags["new.cpp"].back(), "-std=c++23"); -} + expect(fatal(!(flags["new.cpp"].empty()))); + expect(eq(flags["new.cpp"].back(), "-std=c++23")); +}}; -TEST(CxxLayerStandard, AStatedStandardWithoutTheLayerIsNotAnException) { +UTest a_stated_standard_without_the_layer_is_not_an_exception{"CxxLayerStandard/AStatedStandardWithoutTheLayerIsNotAnException", [] { Fixture f; f.providerProvides.clear(); auto flags = plan_flags(f); - EXPECT_FALSE(has(flags["new.cpp"], "-std=c++23")); -} + expect(!has(flags["new.cpp"], "-std=c++23")); +}}; -TEST(CxxLayerStandard, TheLevelIsExactlyTheStatedOneUnderAHigherGraph) { +UTest the_level_is_exactly_the_stated_one_under_a_higher_graph{"CxxLayerStandard/TheLevelIsExactlyTheStatedOneUnderAHigherGraph", [] { Fixture f; f.graphStandard = "c++26"; auto flags = plan_flags(f); - ASSERT_FALSE(flags["new.cpp"].empty()); - EXPECT_EQ(flags["new.cpp"].back(), "-std=c++23"); -} + expect(fatal(!(flags["new.cpp"].empty()))); + expect(eq(flags["new.cpp"].back(), "-std=c++23")); +}}; -TEST(CxxLayerStandard, TheSameLevelAsTheGraphAddsNothing) { +UTest the_same_level_as_the_graph_adds_nothing{"CxxLayerStandard/TheSameLevelAsTheGraphAddsNothing", [] { Fixture f; f.graphStandard = "c++23"; auto flags = plan_flags(f); - EXPECT_FALSE(has(flags["new.cpp"], "-std=c++23")); -} + expect(!has(flags["new.cpp"], "-std=c++23")); +}}; -TEST(CxxLayerStandard, ThePredicateNamesEitherSpellingOfTheLayer) { +UTest the_predicate_names_either_spelling_of_the_layer{"CxxLayerStandard/ThePredicateNamesEitherSpellingOfTheLayer", [] { mcpp::manifest::Manifest m; - EXPECT_FALSE(mcpp::manifest::provides_cxx_layer(m)); + expect(!mcpp::manifest::provides_cxx_layer(m)); m.provides = {"hosted-standard-library"}; - EXPECT_TRUE(mcpp::manifest::provides_cxx_layer(m)); + expect(bool(mcpp::manifest::provides_cxx_layer(m))); m.provides = {"mcpp:c++-abi=libc++"}; - EXPECT_TRUE(mcpp::manifest::provides_cxx_layer(m)); + expect(bool(mcpp::manifest::provides_cxx_layer(m))); m.provides = {"mcpp:c-abi=musl"}; - EXPECT_FALSE(mcpp::manifest::provides_cxx_layer(m)); -} + expect(!mcpp::manifest::provides_cxx_layer(m)); +}}; diff --git a/tests/unit/test_depfile.cpp b/tests/unit/test_depfile.cpp index 0405e9482..5f945fd18 100644 --- a/tests/unit/test_depfile.cpp +++ b/tests/unit/test_depfile.cpp @@ -1,53 +1,52 @@ // mcpp.build.depfile: the first record of a GNU depfile, which // `mcpp depfile-filter` keeps on Windows where POSIX runs the awk program // `NR==1{print;next} /^[^ ]/{exit} {print}` (ninja_backend.cppm, WS2). -#include import std; +import mcpplibs.mtest; import mcpp.build.depfile; +using namespace mcpplibs::mtest; + using mcpp::build::depfile::first_record; // GCC 16 with -fmodules, for a module interface with a purview #include: the // first record is the textual include graph, and the records after it (the // BMI having inputs, the phony module target) are what ninja rejects. -TEST(Depfile, KeepsTheIncludeGraphAndDropsGccsModuleRecords) { +UTest keeps_the_include_graph_and_drops_gccs_module_records{"Depfile/KeepsTheIncludeGraphAndDropsGccsModuleRecords", [] { const std::string raw = "obj/m.m.o gcm.cache/m.gcm: src/m.cppm \\\n" " src/vals.inc\n" "m.c++-module: gcm.cache/m.gcm\n" ".PHONY: m.c++-module\n" "gcm.cache/m.gcm:| obj/m.m.o\n"; - EXPECT_EQ(first_record(raw), - "obj/m.m.o gcm.cache/m.gcm: src/m.cppm \\\n" - " src/vals.inc\n"); -} + expect(eq(first_record(raw), "obj/m.m.o gcm.cache/m.gcm: src/m.cppm \\\n" + " src/vals.inc\n")); +}}; -TEST(Depfile, AnImportingUnitKeepsItsFirstRecordOnly) { +UTest an_importing_unit_keeps_its_first_record_only{"Depfile/AnImportingUnitKeepsItsFirstRecordOnly", [] { const std::string raw = "obj/main.o: src/main.cpp gcm.cache/std.gcm gcm.cache/m.gcm\n" "obj/main.o: m.c++-module std.c++-module\n" "CXX_IMPORTS += m.c++-module std.c++-module\n"; - EXPECT_EQ(first_record(raw), - "obj/main.o: src/main.cpp gcm.cache/std.gcm gcm.cache/m.gcm\n"); -} + expect(eq(first_record(raw), "obj/main.o: src/main.cpp gcm.cache/std.gcm gcm.cache/m.gcm\n")); +}}; // A plain depfile (clang's shape, or GCC's for a unit with no module) is // returned whole, including a final line without a newline. -TEST(Depfile, APlainDepfileIsKeptWhole) { +UTest a_plain_depfile_is_kept_whole{"Depfile/APlainDepfileIsKeptWhole", [] { const std::string raw = "obj/a.o: src/a.cpp \\\n src/a.h \\\n src/b.h"; - EXPECT_EQ(first_record(raw), raw); - EXPECT_EQ(first_record(""), ""); -} + expect(eq(first_record(raw), raw)); + expect(eq(first_record(""), "")); +}}; // Windows paths as a Windows GCC writes them: a drive letter and escaped // spaces do not end the record; only a line that starts with a non-space does. -TEST(Depfile, WindowsPathsAndCrlfLinesStayInTheRecord) { +UTest windows_paths_and_crlf_lines_stay_in_the_record{"Depfile/WindowsPathsAndCrlfLinesStayInTheRecord", [] { const std::string raw = "obj/a.o: C:/src/a.cpp \\\r\n" " C:/Program\\ Files/inc/a.h\r\n" "a.c++-module: gcm.cache/a.gcm\r\n"; - EXPECT_EQ(first_record(raw), - "obj/a.o: C:/src/a.cpp \\\r\n" - " C:/Program\\ Files/inc/a.h\r\n"); -} + expect(eq(first_record(raw), "obj/a.o: C:/src/a.cpp \\\r\n" + " C:/Program\\ Files/inc/a.h\r\n")); +}}; diff --git a/tests/unit/test_descriptor_keys.cpp b/tests/unit/test_descriptor_keys.cpp index f90f4139d..5b58cb977 100644 --- a/tests/unit/test_descriptor_keys.cpp +++ b/tests/unit/test_descriptor_keys.cpp @@ -3,12 +3,14 @@ // build machine takes no part. Both readers -- the version table under `xpm` // and the per-platform table of the `mcpp` segment -- follow the same order. -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.toolchain.platform; +using namespace mcpplibs::mtest; + namespace { using mcpp::toolchain::platform::TargetPlatform; @@ -47,31 +49,31 @@ std::vector versions(std::string_view lua, std::string_view os) { } // namespace -TEST(DescriptorKeys, AWebBuildReadsItsOwnBlockBeforeLinux) { - EXPECT_EQ(versions(kBoth, "emscripten"), (std::vector{"4.0.0"})); - EXPECT_EQ(versions(kBoth, "linux"), (std::vector{"4.1.0"})); -} +UTest a_web_build_reads_its_own_block_before_linux{"DescriptorKeys/AWebBuildReadsItsOwnBlockBeforeLinux", [] { + expect(eq(versions(kBoth, "emscripten"), (std::vector{"4.0.0"}))); + expect(eq(versions(kBoth, "linux"), (std::vector{"4.1.0"}))); +}}; -TEST(DescriptorKeys, AWebBuildFallsToLinuxOnlyByItsRowsOrderNotTheHost) { - EXPECT_EQ(versions(kLinuxOnly, "emscripten"), (std::vector{"4.1.0"})); - EXPECT_EQ(versions(kLinuxOnly, "openkal"), (std::vector{"4.1.0"})); -} +UTest a_web_build_falls_to_linux_only_by_its_rows_order_not_the_host{"DescriptorKeys/AWebBuildFallsToLinuxOnlyByItsRowsOrderNotTheHost", [] { + expect(eq(versions(kLinuxOnly, "emscripten"), (std::vector{"4.1.0"}))); + expect(eq(versions(kLinuxOnly, "openkal"), (std::vector{"4.1.0"}))); +}}; -TEST(DescriptorKeys, ABareMetalBuildReadsNoHostedBlock) { - EXPECT_TRUE(versions(kLinuxOnly, "none").empty()); - EXPECT_TRUE(versions(kLinuxOnly, "windows").empty()); -} +UTest a_bare_metal_build_reads_no_hosted_block{"DescriptorKeys/ABareMetalBuildReadsNoHostedBlock", [] { + expect(versions(kLinuxOnly, "none").empty()); + expect(versions(kLinuxOnly, "windows").empty()); +}}; -TEST(DescriptorKeys, TheSegmentTableFollowsTheSameOrder) { +UTest the_segment_table_follows_the_same_order{"DescriptorKeys/TheSegmentTableFollowsTheSameOrder", [] { auto web = mcpp::manifest::synthesize_from_xpkg_lua(kBoth, "taskflow", "4.0.0", TargetPlatform::for_os("emscripten")); - ASSERT_TRUE(web) << web.error().format(); + if (auto&& failed = expect(fatal(bool(web))); !failed) failed << web.error().format(); auto const& f = web->buildConfig.cxxflags; - EXPECT_NE(std::ranges::find(f, "-DWEB"), f.end()); - EXPECT_EQ(std::ranges::find(f, "-pthread"), f.end()); + expect(ne(std::ranges::find(f, "-DWEB"), f.end())); + expect(eq(std::ranges::find(f, "-pthread"), f.end())); auto ok = mcpp::manifest::synthesize_from_xpkg_lua(kLinuxOnly, "taskflow", "4.1.0", TargetPlatform::for_os("openkal")); - ASSERT_TRUE(ok) << ok.error().format(); - EXPECT_NE(std::ranges::find(ok->buildConfig.cxxflags, "-pthread"), ok->buildConfig.cxxflags.end()); -} + if (auto&& failed = expect(fatal(bool(ok))); !failed) failed << ok.error().format(); + expect(ne(std::ranges::find(ok->buildConfig.cxxflags, "-pthread"), ok->buildConfig.cxxflags.end())); +}}; diff --git a/tests/unit/test_detach_status.cpp b/tests/unit/test_detach_status.cpp index 3a9cdc96b..8f3ced644 100644 --- a/tests/unit/test_detach_status.cpp +++ b/tests/unit/test_detach_status.cpp @@ -1,6 +1,6 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.schedule.detach_codegen; import mcpp.run; import mcpp.invocation; @@ -8,6 +8,8 @@ import mcpplibs.os.fs; import mcpplibs.os.platform; import mcpplibs.os.process; +using namespace mcpplibs::mtest; + // A detached compile reports its status in `.rc`, which appears by a // rename once the number is written. One that holds no number is a write that // failed (a full disk); the waiter takes the unit as failed at once instead of @@ -29,67 +31,66 @@ struct Slot { }; } // namespace -TEST(DetachStatus, AStatusFileWithNoNumberIsAFailedUnit) { +UTest a_status_file_with_no_number_is_a_failed_unit{"DetachStatus/AStatusFileWithNoNumberIsAFailedUnit", [] { Slot s; std::ofstream(s.slot.string() + ".rc") << ""; const auto started = std::chrono::steady_clock::now(); - EXPECT_NE(detach::await_unit(s.slot, {}), 0); - EXPECT_LT(std::chrono::steady_clock::now() - started, std::chrono::seconds(5)); -} + expect(ne(detach::await_unit(s.slot, {}), 0)); + expect(lt(std::chrono::steady_clock::now() - started, std::chrono::seconds(5))); +}}; -TEST(DetachStatus, AWrittenStatusIsTheUnits) { +UTest a_written_status_is_the_units{"DetachStatus/AWrittenStatusIsTheUnits", [] { Slot s; std::ofstream(s.slot.string() + ".rc") << "3\n"; - EXPECT_EQ(detach::await_unit(s.slot, {}), 3); + expect(eq(detach::await_unit(s.slot, {}), 3)); Slot t; std::ofstream(t.slot.string() + ".rc") << "0\n"; - EXPECT_EQ(detach::await_unit(t.slot, {}), 0); -} + expect(eq(detach::await_unit(t.slot, {}), 0)); +}}; -TEST(DetachStatus, OrdinaryCompilerStartFailureReleasesItsPermit) { +UTest ordinary_compiler_start_failure_releases_its_permit{"DetachStatus/OrdinaryCompilerStartFailureReleasesItsPermit", [] { Slot s; const auto semaphore = s.dir / "permits"; - EXPECT_EQ(detach::run_capped({(s.dir / "missing-compiler").string()}, semaphore, 1), 127); + expect(eq(detach::run_capped({(s.dir / "missing-compiler").string()}, semaphore, 1), 127)); auto free = mcpplibs::os::fs::lock::acquire(semaphore / "slot-0.lock", mcpplibs::os::fs::lock::kind::exclusive, false); - EXPECT_TRUE(free); -} + expect(bool(free)); +}}; -TEST(DetachStatus, AnUnavailableSemaphoreRefusesUnboundedCompilation) { +UTest an_unavailable_semaphore_refuses_unbounded_compilation{"DetachStatus/AnUnavailableSemaphoreRefusesUnboundedCompilation", [] { Slot s; const auto semaphore = s.dir / "not-a-directory"; std::ofstream(semaphore) << "occupied"; - EXPECT_NE(detach::run_capped({"unused-compiler"}, semaphore, 1), 0); - EXPECT_TRUE(fs::is_regular_file(semaphore)); -} + expect(ne(detach::run_capped({"unused-compiler"}, semaphore, 1), 0)); + expect(fs::is_regular_file(semaphore)); +}}; -TEST(DetachStatus, KernelSlotsDistinguishContentionFromFailure) { +UTest kernel_slots_distinguish_contention_from_failure{"DetachStatus/KernelSlotsDistinguishContentionFromFailure", [] { Slot s; const auto path = s.dir / "slot.lock"; { auto held = mcpplibs::os::fs::lock::acquire(path, mcpplibs::os::fs::lock::kind::exclusive, false); - ASSERT_TRUE(held); + expect(fatal(bool(held))); auto busy = mcpplibs::os::fs::lock::acquire(path, mcpplibs::os::fs::lock::kind::exclusive, false); - ASSERT_FALSE(busy); - EXPECT_EQ(busy.error().code, mcpplibs::os::errc::again); + expect(fatal(!busy)); + expect(eq(busy.error().code, mcpplibs::os::errc::again)); } - EXPECT_TRUE(fs::exists(path)); + expect(fs::exists(path)); auto reused = mcpplibs::os::fs::lock::acquire(path, mcpplibs::os::fs::lock::kind::exclusive, false); - EXPECT_TRUE(reused); -} + expect(bool(reused)); +}}; namespace { std::string lifetime_env(std::string_view key, std::string fallback = {}) { return mcpp::invocation::get(key).value_or(std::move(fallback)); } -int lifetime_driver() { +int lifetime_driver(const std::vector& argv) { const auto role = lifetime_env("MCPP_TEST_LIFETIME_ROLE"); const auto self = mcpp::invocation::self_exe_path(); - const auto argv = ::testing::internal::GetArgvs(); if (argv.size() > 1 && argv[1] == "bmi-supervise") { auto value = [&](std::string_view key) { auto it = std::ranges::find(argv, key); @@ -177,48 +178,49 @@ int lifetime_driver() { } } // namespace -// This copy is an actual detached supervisor/phase1/ordinary compiler under -// Ninja. It is selected through GTEST_FILTER inherited by detached children. -TEST(DetachLifetime, SubprocessDriver) { - if (lifetime_env("MCPP_TEST_LIFETIME_ROLE").empty()) GTEST_SKIP() << "subprocess driver"; +// A COPY OF THIS PROGRAM IS A DETACHED SUPERVISOR, PHASE-ONE OR ORDINARY COMPILER +// UNDER NINJA, chosen by MCPP_TEST_LIFETIME_ROLE (the e2e helpers set it) before any +// case runs; the copy is mcpp to the code under test, so its arguments are mcpp's. +int main(int argc, char** argv) { + if (lifetime_env("MCPP_TEST_LIFETIME_ROLE").empty()) return mcpplibs::mtest::run(argc, argv); mcpp::run::install(); - const int rc = lifetime_driver(); + const int rc = lifetime_driver(std::vector(argv, argv + argc)); std::cout.flush(); std::cerr.flush(); std::_Exit(rc); } -TEST(DetachLifetime, OwnerCancellationAndImmediateRestart) { - if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "Linux signal and LD_PRELOAD controls"; +UTest owner_cancellation_and_immediate_restart{"DetachLifetime/OwnerCancellationAndImmediateRestart", [] { + if constexpr (!mcpplibs::os::is_linux) skip("Linux signal and LD_PRELOAD controls"); Slot temporary; const auto helper = fs::current_path() / "tests/e2e/helpers/check_owned_schedule_lifetime.py"; - ASSERT_TRUE(fs::exists(helper)); + expect(fatal(fs::exists(helper))); const auto result = mcpp::run::execute({"python3", helper.string(), mcpp::invocation::self_exe_path().string(), temporary.dir.string()}); - EXPECT_EQ(result.exit_code, 0) << result.output; -} + expect(eq(result.exit_code, 0)) << result.output; +}}; -TEST(DetachLifetime, TransactionRecoveryAtNativeSyscallBoundaries) { +UTest transaction_recovery_at_native_syscall_boundaries{"DetachLifetime/TransactionRecoveryAtNativeSyscallBoundaries", [] { if constexpr (!mcpplibs::os::is_linux || mcpplibs::os::ARCH != mcpplibs::os::architecture::x86_64) - GTEST_SKIP() << "Linux x86-64 syscall boundary control"; + skip("Linux x86-64 syscall boundary control"); Slot temporary; const auto helper=fs::current_path()/"tests/e2e/helpers/check_compiler_transaction_faults.py"; - ASSERT_TRUE(fs::exists(helper)); + expect(fatal(fs::exists(helper))); const auto result=mcpp::run::execute({"python3",helper.string(), mcpp::invocation::self_exe_path().string(),temporary.dir.string()}, {.total=std::chrono::seconds(120)}); - EXPECT_EQ(result.exit_code,0)< import std; +import mcpplibs.mtest; +import mcpplibs.os.stdio; import mcpp.diag; +using namespace mcpplibs::mtest; + using namespace mcpp::diag; namespace { -struct DiagTest : ::testing::Test { - void SetUp() override { reset(); } - void TearDown() override { reset(); } +struct DiagTest { + void SetUp() { reset(); } + void TearDown() { reset(); } }; } // namespace -TEST_F(DiagTest, WarningAndDegradedAreCountedSeparately) { +UTest warning_and_degraded_are_counted_separately{"DiagTest/WarningAndDegradedAreCountedSeparately", [] { + struct Case : DiagTest { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { warning("manifest/schema", "unsupported key 'bogus'"); degraded("build/depfile", "no GNU depfile on this toolchain", "edits to purview-included files will not trigger a rebuild"); - EXPECT_EQ(count(Severity::Warning), 1u); - EXPECT_EQ(count(Severity::Degraded), 1u); - EXPECT_EQ(records().size(), 2u); + expect(eq(count(Severity::Warning), 1u)); + expect(eq(count(Severity::Degraded), 1u)); + expect(eq(records().size(), 2u)); } - -TEST_F(DiagTest, IdenticalReportsAreDeduplicated) { + } fixture; + fixture.body(); +}}; + +UTest identical_reports_are_deduplicated{"DiagTest/IdenticalReportsAreDeduplicated", [] { + struct Case : DiagTest { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { for (int i = 0; i < 5; ++i) warning("modgraph/scan", "glob 'a/**' matched no source file"); - EXPECT_EQ(records().size(), 1u) + expect(eq(records().size(), 1u)) << "a repeated report should render and record once"; } - -TEST_F(DiagTest, SameDomainDifferentPayloadStaysDistinct) { + } fixture; + fixture.body(); +}}; + +UTest same_domain_different_payload_stays_distinct{"DiagTest/SameDomainDifferentPayloadStaysDistinct", [] { + struct Case : DiagTest { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { // Two degradations in one domain that tell the user different things are // two records — dedup keys on the whole payload, not the domain. degraded("build/depfile", "no GNU depfile", "stale BMI possible"); degraded("build/depfile", "no GNU depfile", "stale object possible"); - EXPECT_EQ(records().size(), 2u); + expect(eq(records().size(), 2u)); } - -TEST_F(DiagTest, FormatKeepsWhatOnTheFirstLine) { + } fixture; + fixture.body(); +}}; + +UTest format_keeps_what_on_the_first_line{"DiagTest/FormatKeepsWhatOnTheFirstLine", [] { + struct Case : DiagTest { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { // e2e assertions grep for the `what` substring; elaboration must not get // in front of it. warning("modgraph/scan", "[build].flags glob 'x/**' matched no source file"); auto rs = records(); - ASSERT_EQ(rs.size(), 1u); - EXPECT_EQ(rs[0].format(), "[build].flags glob 'x/**' matched no source file"); + expect(fatal(eq(rs.size(), 1u))); + expect(eq(rs[0].format(), "[build].flags glob 'x/**' matched no source file")); reset(); degraded("build/depfile", "what happened", "the impact", "the hint"); rs = records(); - ASSERT_EQ(rs.size(), 1u); - EXPECT_TRUE(rs[0].format().starts_with("what happened\n")); - EXPECT_NE(rs[0].format().find("impact: the impact"), std::string::npos); - EXPECT_NE(rs[0].format().find("help: the hint"), std::string::npos); + expect(fatal(eq(rs.size(), 1u))); + expect(rs[0].format().starts_with("what happened\n")); + expect(ne(rs[0].format().find("impact: the impact"), std::string::npos)); + expect(ne(rs[0].format().find("help: the hint"), std::string::npos)); } - -TEST_F(DiagTest, StrictPromotesDegradationsOnly) { + } fixture; + fixture.body(); +}}; + +UTest strict_promotes_degradations_only{"DiagTest/StrictPromotesDegradationsOnly", [] { + struct Case : DiagTest { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { warning("manifest/schema", "just a warning"); - EXPECT_TRUE(flush(/*strict=*/true)) + expect(bool(flush(/*strict=*/true))) << "a plain warning must not fail a --strict run"; reset(); degraded("build/depfile", "w", "i"); - EXPECT_FALSE(flush(/*strict=*/true)); + expect(!flush(/*strict=*/true)); reset(); degraded("build/depfile", "w", "i"); - EXPECT_TRUE(flush(/*strict=*/false)); + expect(bool(flush(/*strict=*/false))); } - -TEST_F(DiagTest, FlushClearsRunState) { + } fixture; + fixture.body(); +}}; + +UTest flush_clears_run_state{"DiagTest/FlushClearsRunState", [] { + struct Case : DiagTest { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { warning("a", "b"); degraded("c", "d", "e"); - EXPECT_TRUE(flush(/*strict=*/false)); - EXPECT_EQ(records().size(), 0u); - EXPECT_EQ(count(Severity::Degraded), 0u); + expect(bool(flush(/*strict=*/false))); + expect(eq(records().size(), 0u)); + expect(eq(count(Severity::Degraded), 0u)); } + } fixture; + fixture.body(); +}}; // Regression guard for the review finding on this batch: flush() is the ONLY // place the --strict policy is settled, and it was initially never called // outside this file — the channel reported degradations and then everyone // ignored them, which is precisely the failure mode it exists to prevent. // run_build_plan now calls it; this pins the contract flush() must honour. -TEST_F(DiagTest, FlushIsTheSolePolicyPointAndReportsFailureToTheCaller) { +UTest flush_is_the_sole_policy_point_and_reports_failure_to_the_caller{"DiagTest/FlushIsTheSolePolicyPointAndReportsFailureToTheCaller", [] { + struct Case : DiagTest { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { // No records at all: strict must not fail a clean build. - EXPECT_TRUE(flush(/*strict=*/true)); + expect(bool(flush(/*strict=*/true))); // A degradation under --strict must tell the caller to fail. The caller // (run_build_plan) turns this into a non-zero exit. degraded("build/depfile", "no depfile on this toolchain", "stale BMI possible after editing an included file"); - EXPECT_FALSE(flush(/*strict=*/true)); + expect(!flush(/*strict=*/true)); // Same degradation without --strict: reported, not fatal. degraded("build/depfile", "no depfile on this toolchain", "stale BMI possible after editing an included file"); - EXPECT_TRUE(flush(/*strict=*/false)); + expect(bool(flush(/*strict=*/false))); } + } fixture; + fixture.body(); +}}; // WS3 of the 2026-09-28 design: one statement per fact per PROCESS. A // `--workspace` build flushes after each member, and a fact every member // shares -- a redundant word the workspace states -- was printed once per // member. The terminal prints it once; each run's record still holds it, so // `take()` gives every member's occurrence to a machine-readable envelope. -TEST_F(DiagTest, AFactIsPrintedOncePerProcessAndRecordedPerRun) { +UTest a_fact_is_printed_once_per_process_and_recorded_per_run{"DiagTest/AFactIsPrintedOncePerProcessAndRecordedPerRun", [] { + struct Case : DiagTest { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { const std::string fact = "the CRT word is redundant ([workspace.build] dialect_cxxflags)"; - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); for (int member = 0; member < 5; ++member) { warning("build/msvc-crt-word", fact); auto run = take(); - ASSERT_EQ(run.size(), 1u) << "member " << member << " lost its own record"; - EXPECT_EQ(run[0].what, fact); + expect(fatal(eq(run.size(), 1u))) << "member " << member << " lost its own record"; + expect(eq(run[0].what, fact)); (void)flush(/*strict=*/false); } - const auto printed = testing::internal::GetCapturedStderr(); + const auto printed = stderrCapture.text(); std::size_t count = 0; for (auto at = printed.find(fact); at != std::string::npos; at = printed.find(fact, at + 1)) ++count; - EXPECT_EQ(count, 1u) << printed; + expect(eq(count, 1u)) << printed; } - -TEST_F(DiagTest, NotesArePrintedAsNotesAndNeverPromoted) { - testing::internal::CaptureStderr(); + } fixture; + fixture.body(); +}}; + +UTest notes_are_printed_as_notes_and_never_promoted{"DiagTest/NotesArePrintedAsNotesAndNeverPromoted", [] { + struct Case : DiagTest { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); note("build/runtime-placement", "a dependency ships the MSVC C++ runtime"); - const auto printed = testing::internal::GetCapturedStderr(); - EXPECT_NE(printed.find("note:"), std::string::npos) << printed; - EXPECT_EQ(printed.find("warning:"), std::string::npos) << printed; - EXPECT_EQ(count(Severity::Degraded), 0u); - EXPECT_TRUE(flush(/*strict=*/true)); + const auto printed = stderrCapture.text(); + expect(ne(printed.find("note:"), std::string::npos)) << printed; + expect(eq(printed.find("warning:"), std::string::npos)) << printed; + expect(eq(count(Severity::Degraded), 0u)); + expect(bool(flush(/*strict=*/true))); } + } fixture; + fixture.body(); +}}; diff --git a/tests/unit/test_dialect.cpp b/tests/unit/test_dialect.cpp index cdc97a2da..3730e52f6 100644 --- a/tests/unit/test_dialect.cpp +++ b/tests/unit/test_dialect.cpp @@ -1,55 +1,55 @@ -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.dialect; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + // The MSVC row is unreachable in a real build until the cl.exe backend lands, // so these tests are the only thing keeping it honest. -TEST(Dialect, LibFlagHasBothShapes) { +UTest lib_flag_has_both_shapes{"Dialect/LibFlagHasBothShapes", [] { // GNU names a library by prefixing, MSVC by suffixing. A single // string_view prefix cannot express `z.lib`, which is why libFlag is a // format rather than a prefix. - EXPECT_EQ(mcpp::toolchain::lib_flag_for(mcpp::toolchain::gnu_dialect(), "z"), - "-lz"); - EXPECT_EQ(mcpp::toolchain::lib_flag_for(mcpp::toolchain::msvc_dialect(), "z"), - "z.lib"); -} - -TEST(Dialect, LibFlagHandlesDottedAndHyphenatedNames) { - EXPECT_EQ(mcpp::toolchain::lib_flag_for(mcpp::toolchain::gnu_dialect(), - "avcodec-60"), "-lavcodec-60"); - EXPECT_EQ(mcpp::toolchain::lib_flag_for(mcpp::toolchain::msvc_dialect(), - "avcodec-60"), "avcodec-60.lib"); -} + expect(eq(mcpp::toolchain::lib_flag_for(mcpp::toolchain::gnu_dialect(), "z"), "-lz")); + expect(eq(mcpp::toolchain::lib_flag_for(mcpp::toolchain::msvc_dialect(), "z"), "z.lib")); +}}; + +UTest lib_flag_handles_dotted_and_hyphenated_names{"Dialect/LibFlagHandlesDottedAndHyphenatedNames", [] { + expect(eq(mcpp::toolchain::lib_flag_for(mcpp::toolchain::gnu_dialect(), + "avcodec-60"), "-lavcodec-60")); + expect(eq(mcpp::toolchain::lib_flag_for(mcpp::toolchain::msvc_dialect(), + "avcodec-60"), "avcodec-60.lib")); +}}; // Every dialect field must be populated in both rows. An empty one silently // emits nothing, which for staticRuntime or forceCxxLang means the compile // changes meaning rather than failing. -TEST(Dialect, LinkAndLanguageFieldsPopulatedInBothRows) { +UTest link_and_language_fields_populated_in_both_rows{"Dialect/LinkAndLanguageFieldsPopulatedInBothRows", [] { for (auto const* d : { &mcpp::toolchain::gnu_dialect(), &mcpp::toolchain::msvc_dialect() }) { - EXPECT_FALSE(d->libFlag.empty()) << d->id; - EXPECT_FALSE(d->libSearchPrefix.empty()) << d->id; - EXPECT_FALSE(d->forceCxxLang.empty()) << d->id; - EXPECT_FALSE(d->staticRuntime.empty()) << d->id; - EXPECT_FALSE(d->outputExePrefix.empty()) << d->id; + if (auto&& failed = expect(!(d->libFlag.empty())); !failed) failed << d->id; + if (auto&& failed = expect(!(d->libSearchPrefix.empty())); !failed) failed << d->id; + if (auto&& failed = expect(!(d->forceCxxLang.empty())); !failed) failed << d->id; + if (auto&& failed = expect(!(d->staticRuntime.empty())); !failed) failed << d->id; + if (auto&& failed = expect(!(d->outputExePrefix.empty())); !failed) failed << d->id; // A `{}` placeholder is what makes lib_flag_for work at all. - EXPECT_NE(d->libFlag.find("{}"), std::string_view::npos) << d->id; + expect(ne(d->libFlag.find("{}"), std::string_view::npos)) << d->id; } -} +}}; // dialect_for must keep routing clang-targeting-MSVC to the gnu spellings: // that driver takes GNU flags even though its ABI and STL are Microsoft's. -TEST(Dialect, OnlyNativeClExeGetsTheMsvcRow) { +UTest only_native_cl_exe_gets_the_msvc_row{"Dialect/OnlyNativeClExeGetsTheMsvcRow", [] { mcpp::toolchain::Toolchain clangMsvc; clangMsvc.compiler = mcpp::toolchain::CompilerId::Clang; clangMsvc.targetTriple = "x86_64-pc-windows-msvc"; - EXPECT_EQ(mcpp::toolchain::dialect_for(clangMsvc).id, "gnu"); + expect(eq(mcpp::toolchain::dialect_for(clangMsvc).id, "gnu")); mcpp::toolchain::Toolchain cl; cl.compiler = mcpp::toolchain::CompilerId::MSVC; cl.targetTriple = "x86_64-pc-windows-msvc"; - EXPECT_EQ(mcpp::toolchain::dialect_for(cl).id, "msvc"); -} + expect(eq(mcpp::toolchain::dialect_for(cl).id, "msvc")); +}}; diff --git a/tests/unit/test_distribution.cpp b/tests/unit/test_distribution.cpp index b5869851d..8c47e7b36 100644 --- a/tests/unit/test_distribution.cpp +++ b/tests/unit/test_distribution.cpp @@ -6,11 +6,13 @@ // TABLE's totality and about each cell's identity — not about "the flags look // roughly right". -#include import std; +import mcpplibs.mtest; import mcpp.build.distribution; +using namespace mcpplibs::mtest; + namespace dist = mcpp::build::dist; namespace { @@ -41,106 +43,103 @@ dist::MechanismInput linux_gcc_input() { // 0.0.86 on, while the docs kept promising the opt-out. The table cannot // reproduce that bug because the role is an INPUT to one function rather than // a switch that picks between two independently-computed strings. -TEST(Distribution, HostCoupledReachesTestBinariesOnMacos) { +UTest host_coupled_reaches_test_binaries_on_macos{"Distribution/HostCoupledReachesTestBinariesOnMacos", [] { auto in = macos_input(); in.requested = dist::Contract::HostCoupled; for (auto role : {dist::Role::Distributable, dist::Role::Test}) { in.role = role; auto m = dist::resolve(in); - EXPECT_EQ(m.effective, dist::Contract::HostCoupled) << dist::to_string(role); - EXPECT_EQ(m.unitFlags, " -lc++") << dist::to_string(role); - EXPECT_FALSE(m.degraded) << dist::to_string(role); + expect(eq(m.effective, dist::Contract::HostCoupled)) << dist::to_string(role); + expect(eq(m.unitFlags, " -lc++")) << dist::to_string(role); + expect(!m.degraded) << dist::to_string(role); // No static libc++ means no Mach-O initializer-ordering problem. - EXPECT_FALSE(m.streamInitShim) << dist::to_string(role); + expect(!m.streamInitShim) << dist::to_string(role); } -} +}}; // The other half of #336: by default a test binary keeps the SAME // self-contained runtime as a shipped one. Flipping this default would // re-open #202 (system libc++ dylib against toolchain libc++ headers → // undefined __hash_memory on libc++ 22), so it is asserted, not assumed. -TEST(Distribution, TestsDefaultToSelfContained) { +UTest tests_default_to_self_contained{"Distribution/TestsDefaultToSelfContained", [] { for (auto fmt : {dist::Format::Elf, dist::Format::MachO, dist::Format::Pe}) { - EXPECT_EQ(dist::default_contract(dist::Role::Test, fmt), - dist::Contract::SelfContained); - EXPECT_EQ(dist::default_contract(dist::Role::Distributable, fmt), - dist::Contract::SelfContained); + expect(eq(dist::default_contract(dist::Role::Test, fmt), dist::Contract::SelfContained)); + expect(eq(dist::default_contract(dist::Role::Distributable, fmt), dist::Contract::SelfContained)); } auto in = macos_input(); in.role = dist::Role::Test; in.requested = dist::default_contract(dist::Role::Test, dist::Format::MachO); auto m = dist::resolve(in); - EXPECT_NE(m.unitFlags.find("-load_hidden"), std::string::npos); - EXPECT_TRUE(m.streamInitShim); -} + expect(ne(m.unitFlags.find("-load_hidden"), std::string::npos)); + expect(bool(m.streamInitShim)); +}}; // -load_hidden, not a plain by-path link: the visibility is the load-bearing // half (PR #117 — default-visibility statics get unified with the system // libc++ from dyld's shared cache and ostream<(fmt), sl, dist::to_string(role), archives); // Totality: every cell answers with a contract it actually delivered. - EXPECT_TRUE(m.effective == dist::Contract::SelfContained + expect(m.effective == dist::Contract::SelfContained || m.effective == dist::Contract::ToolchainCoupled || m.effective == dist::Contract::HostCoupled) << where; // Honesty: a downgrade is never silent. - if (m.degraded) EXPECT_FALSE(m.diagnostic.empty()) << where; + if (m.degraded) expect(!m.diagnostic.empty()) << where; if (m.effective != c && role != dist::Role::Intermediate) - EXPECT_TRUE(m.degraded) << where; + expect(bool(m.degraded)) << where; // The ordering shim is a Mach-O static-libc++ concern and nothing else. if (m.streamInitShim) { - EXPECT_EQ(fmt, dist::Format::MachO) << where; - EXPECT_EQ(m.effective, dist::Contract::SelfContained) << where; + expect(eq(fmt, dist::Format::MachO)) << where; + expect(eq(m.effective, dist::Contract::SelfContained)) << where; } } -} +}}; // The generated shim is load-bearing in three specific ways; a well-meaning // edit that drops any of them turns it into a silent no-op (or worse, an // unconditional link failure on a toolchain that spells the symbol // differently). See issue #336 for the disassembly this rests on. -TEST(Distribution, StreamInitShimKeepsItsThreeLoadBearingProperties) { +UTest stream_init_shim_keeps_its_three_load_bearing_properties{"Distribution/StreamInitShimKeepsItsThreeLoadBearingProperties", [] { auto src = std::string(dist::stream_init_shim_source()); // 1. weak_import — Mach-O's weak-UNDEFINED form. Plain `weak` is not it; // the first CI round proved that by failing every macOS link. - EXPECT_NE(src.find("__attribute__((weak_import))"), std::string::npos); + expect(ne(src.find("__attribute__((weak_import))"), std::string::npos)); // 2. ios_base::Init::Init, NOT DoIOSInit::DoIOSInit — the former is // guarded by __cxa_guard, so libc++'s own initializer later becomes a // no-op instead of placement-new'ing over live streams. // ...spelled with TWO leading underscores: an __asm__ label is used // verbatim, so Mach-O's global `_` prefix has to be written out. - EXPECT_NE(src.find("\"__ZNSt3__18ios_base4InitC1Ev\""), std::string::npos); - EXPECT_EQ(src.find("DoIOSInit"), std::string::npos); + expect(ne(src.find("\"__ZNSt3__18ios_base4InitC1Ev\""), std::string::npos)); + expect(eq(src.find("DoIOSInit"), std::string::npos)); // 3. a constructor, and it must be guarded by the weak null check. - EXPECT_NE(src.find("__attribute__((constructor))"), std::string::npos); - EXPECT_NE(src.find("if (mcpp_libcxx_ios_init)"), std::string::npos); + expect(ne(src.find("__attribute__((constructor))"), std::string::npos)); + expect(ne(src.find("if (mcpp_libcxx_ios_init)"), std::string::npos)); // C, not C++: no standard library, no module flags, no ABI of its own. - EXPECT_EQ(src.find("#include"), std::string::npos); -} + expect(eq(src.find("#include"), std::string::npos)); +}}; // The shim binds a libc++ INTERNAL symbol. If that symbol is not in the // archive, mcpp must NOT generate the reference — an undefined symbol fails // the link outright (ld64.lld does not treat a weak declaration as an // optional undefined). The absence is reported instead, because the startup // hazard is still there. -TEST(Distribution, ShimIsNotGeneratedWhenTheSymbolIsAbsent) { +UTest shim_is_not_generated_when_the_symbol_is_absent{"Distribution/ShimIsNotGeneratedWhenTheSymbolIsAbsent", [] { auto in = macos_input(); in.streamInitSymbolPresent = false; auto m = dist::resolve(in); - EXPECT_FALSE(m.streamInitShim); - EXPECT_FALSE(m.diagnostic.empty()); + expect(!m.streamInitShim); + expect(!m.diagnostic.empty()); // The contract itself is still honored — only the ordering aid is gone. - EXPECT_EQ(m.effective, dist::Contract::SelfContained); - EXPECT_NE(m.unitFlags.find("-load_hidden"), std::string::npos); -} + expect(eq(m.effective, dist::Contract::SelfContained)); + expect(ne(m.unitFlags.find("-load_hidden"), std::string::npos)); +}}; // --------------------------------------------------------------------------- // Shared libraries. @@ -513,64 +506,61 @@ TEST(Distribution, ShimIsNotGeneratedWhenTheSymbolIsAbsent) { // The whole table is asserted cell by cell rather than "the ELF case", because // the reason the other two formats keep the old answer is a real argument // about each of them and a regression there would be silent. -TEST(Distribution, SharedLibraryDefaultIsFormatSpecific) { - EXPECT_EQ(dist::default_contract(dist::Role::SharedLibrary, dist::Format::Elf), - dist::Contract::ToolchainCoupled); +UTest shared_library_default_is_format_specific{"Distribution/SharedLibraryDefaultIsFormatSpecific", [] { + expect(eq(dist::default_contract(dist::Role::SharedLibrary, dist::Format::Elf), dist::Contract::ToolchainCoupled)); // Mach-O: self-contained ALREADY means hidden (-load_hidden), so dyld // cannot unify the symbols; and toolchain-coupled is a documented dead end // there (#202). PE: no global namespace at all, imports resolve per-DLL. - EXPECT_EQ(dist::default_contract(dist::Role::SharedLibrary, dist::Format::MachO), - dist::Contract::SelfContained); - EXPECT_EQ(dist::default_contract(dist::Role::SharedLibrary, dist::Format::Pe), - dist::Contract::SelfContained); -} + expect(eq(dist::default_contract(dist::Role::SharedLibrary, dist::Format::MachO), dist::Contract::SelfContained)); + expect(eq(dist::default_contract(dist::Role::SharedLibrary, dist::Format::Pe), dist::Contract::SelfContained)); +}}; // The ELF mechanism for the new default: nothing. The driver links // libstdc++.so and the toolchain's lib directory is already an -L and an rpath // entry on the line. "No flag" has to be asserted or a future edit that adds // one back would look like an improvement. -TEST(Distribution, SharedLibraryOnElfEmbedsNothing) { +UTest shared_library_on_elf_embeds_nothing{"Distribution/SharedLibraryOnElfEmbedsNothing", [] { auto in = linux_gcc_input(); in.role = dist::Role::SharedLibrary; in.requested = dist::default_contract(dist::Role::SharedLibrary, dist::Format::Elf); auto m = dist::resolve(in); - EXPECT_EQ(m.effective, dist::Contract::ToolchainCoupled); - EXPECT_EQ(m.unitFlags, ""); - EXPECT_FALSE(m.degraded); - EXPECT_TRUE(m.diagnostic.empty()); -} + expect(eq(m.effective, dist::Contract::ToolchainCoupled)); + expect(eq(m.unitFlags, "")); + expect(!m.degraded); + expect(m.diagnostic.empty()); +}}; // The escape hatch stays usable — and is guarded. Asking for a self-contained // .so is legitimate; letting it export the embedded runtime is not. -TEST(Distribution, ExplicitSelfContainedSharedLibraryHidesTheEmbeddedRuntime) { +UTest explicit_self_contained_shared_library_hides_the_embedded_runtime{"Distribution/ExplicitSelfContainedSharedLibraryHidesTheEmbeddedRuntime", [] { auto in = linux_gcc_input(); in.role = dist::Role::SharedLibrary; in.requested = dist::Contract::SelfContained; in.explicitRequest = true; auto m = dist::resolve(in); - EXPECT_EQ(m.effective, dist::Contract::SelfContained); - EXPECT_NE(m.unitFlags.find("-static-libstdc++"), std::string::npos) << m.unitFlags; - EXPECT_NE(m.unitFlags.find("-Wl,--exclude-libs,libstdc++.a"), std::string::npos) + expect(eq(m.effective, dist::Contract::SelfContained)); + expect(ne(m.unitFlags.find("-static-libstdc++"), std::string::npos)) << m.unitFlags; + expect(ne(m.unitFlags.find("-Wl,--exclude-libs,libstdc++.a"), std::string::npos)) << m.unitFlags; // No diagnostic: this is honored exactly as asked, not degraded. - EXPECT_FALSE(m.degraded); -} + expect(!m.degraded); +}}; // The guard is for shared libraries only. An executable's static libstdc++ is // already local (ld exports only what a loaded object references and mcpp // passes no -rdynamic), so hiding it would be noise — and `--exclude-libs` on // an executable link is a flag whose absence is part of the contract. -TEST(Distribution, ExecutablesDoNotGetTheExcludeLibsGuard) { +UTest executables_do_not_get_the_exclude_libs_guard{"Distribution/ExecutablesDoNotGetTheExcludeLibsGuard", [] { auto in = linux_gcc_input(); in.role = dist::Role::Distributable; in.requested = dist::Contract::SelfContained; auto m = dist::resolve(in); - EXPECT_EQ(m.unitFlags, " -static-libstdc++"); -} + expect(eq(m.unitFlags, " -static-libstdc++")); +}}; // Same rule on the libc++/ELF path: the archives are linked by PATH, but // --exclude-libs matches the archive BASENAME, so the names are spelled out. -TEST(Distribution, LibcxxSelfContainedSharedLibraryHidesItsArchives) { +UTest libcxx_self_contained_shared_library_hides_its_archives{"Distribution/LibcxxSelfContainedSharedLibraryHidesItsArchives", [] { dist::MechanismInput in; in.format = dist::Format::Elf; in.stdlibId = "libc++"; @@ -584,32 +574,31 @@ TEST(Distribution, LibcxxSelfContainedSharedLibraryHidesItsArchives) { for (auto needle : {"-Wl,--exclude-libs,libc++.a", "-Wl,--exclude-libs,libc++abi.a", "-Wl,--exclude-libs,libunwind.a"}) - EXPECT_NE(m.unitFlags.find(needle), std::string::npos) << needle + expect(ne(m.unitFlags.find(needle), std::string::npos)) << needle << " / " << m.unitFlags; -} +}}; // An archive embeds no runtime, so the role returns before any mechanism runs. // Adding a fourth role must not have perturbed that early exit. -TEST(Distribution, IntermediateStillCarriesNoMechanism) { +UTest intermediate_still_carries_no_mechanism{"Distribution/IntermediateStillCarriesNoMechanism", [] { auto in = linux_gcc_input(); in.role = dist::Role::Intermediate; in.requested = dist::Contract::SelfContained; auto m = dist::resolve(in); - EXPECT_EQ(m.unitFlags, ""); - EXPECT_FALSE(m.degraded); -} + expect(eq(m.unitFlags, "")); + expect(!m.degraded); +}}; // `ldStdlibByRole` indexes by the enum value. If a role is ever inserted // rather than appended, every stored contract silently re-maps. -TEST(Distribution, RoleCountCoversEveryRole) { - EXPECT_EQ(static_cast(dist::Role::SharedLibrary) + 1, - dist::kRoleCount); +UTest role_count_covers_every_role{"Distribution/RoleCountCoversEveryRole", [] { + expect(eq(static_cast(dist::Role::SharedLibrary) + 1, dist::kRoleCount)); for (auto r : {dist::Role::Distributable, dist::Role::Test, dist::Role::Intermediate, dist::Role::SharedLibrary}) { - EXPECT_LT(static_cast(r), dist::kRoleCount); - EXPECT_FALSE(dist::to_string(r).empty()); + expect(lt(static_cast(r), dist::kRoleCount)); + expect(!(dist::to_string(r).empty())); } -} +}}; // ── The two short-circuits: when there is no C++ runtime to distribute WITH ── // @@ -619,7 +608,7 @@ TEST(Distribution, RoleCountCoversEveryRole) { // rather than merely unnecessary, and in both the archives the table would // reach for belong to the HOST. -TEST(Distribution, FreestandingCarriesNoRuntimeToDistribute) { +UTest freestanding_carries_no_runtime_to_distribute{"Distribution/FreestandingCarriesNoRuntimeToDistribute", [] { dist::MechanismInput in; in.format = dist::Format::Elf; in.stdlibId = "libc++"; @@ -630,31 +619,31 @@ TEST(Distribution, FreestandingCarriesNoRuntimeToDistribute) { in.libcxxArchive = "/tc/lib/libc++.a"; in.libcxxAbiArchive = "/tc/lib/libc++abi.a"; auto m = dist::resolve(in); - EXPECT_EQ(m.effective, dist::Contract::SelfContained); - EXPECT_EQ(m.unitFlags, " -nostdlib++"); - EXPECT_EQ(m.unitFlags.find("libc++.a"), std::string::npos); - EXPECT_FALSE(m.degraded); -} + expect(eq(m.effective, dist::Contract::SelfContained)); + expect(eq(m.unitFlags, " -nostdlib++")); + expect(eq(m.unitFlags.find("libc++.a"), std::string::npos)); + expect(!m.degraded); +}}; // The hosted form of the same fact. A package in the graph has compiled a // C++ runtime FOR THIS TARGET and its objects are already on the link line, so // there is no library to name and nothing to look for. Measured before this // existed: `ld64.lld: error: library not found for -lc++`. -TEST(Distribution, GraphSuppliedRuntimeCarriesNoLibraryToName) { +UTest graph_supplied_runtime_carries_no_library_to_name{"Distribution/GraphSuppliedRuntimeCarriesNoLibraryToName", [] { auto in = macos_input(); in.graphCxxRuntime = true; in.requested = dist::Contract::SelfContained; in.role = dist::Role::Distributable; auto m = dist::resolve(in); - EXPECT_EQ(m.effective, dist::Contract::SelfContained); - EXPECT_EQ(m.unitFlags, " -nostdlib++"); - EXPECT_EQ(m.unitFlags.find("-lc++"), std::string::npos); - EXPECT_EQ(m.unitFlags.find("load_hidden"), std::string::npos); -} + expect(eq(m.effective, dist::Contract::SelfContained)); + expect(eq(m.unitFlags, " -nostdlib++")); + expect(eq(m.unitFlags.find("-lc++"), std::string::npos)); + expect(eq(m.unitFlags.find("load_hidden"), std::string::npos)); +}}; // The same on every format, because the fact is about the graph and not about // the object format. -TEST(Distribution, GraphSuppliedRuntimeIsFormatIndependent) { +UTest graph_supplied_runtime_is_format_independent{"Distribution/GraphSuppliedRuntimeIsFormatIndependent", [] { for (auto fmt : {dist::Format::Elf, dist::Format::MachO, dist::Format::Pe}) { dist::MechanismInput in; in.format = fmt; @@ -663,15 +652,15 @@ TEST(Distribution, GraphSuppliedRuntimeIsFormatIndependent) { in.requested = dist::Contract::SelfContained; in.role = dist::Role::Distributable; auto m = dist::resolve(in); - EXPECT_EQ(m.unitFlags, " -nostdlib++") ; - EXPECT_EQ(m.effective, dist::Contract::SelfContained); + expect(eq(m.unitFlags, " -nostdlib++")) ; + expect(eq(m.effective, dist::Contract::SelfContained)); } -} +}}; // And it does not depend on the contract the project asked for: a // host-coupled request cannot be honoured by naming the system's runtime when // the graph's is already inside the artefact. -TEST(Distribution, GraphSuppliedRuntimeIgnoresTheRequestedContract) { +UTest graph_supplied_runtime_ignores_the_requested_contract{"Distribution/GraphSuppliedRuntimeIgnoresTheRequestedContract", [] { for (auto c : {dist::Contract::SelfContained, dist::Contract::ToolchainCoupled, dist::Contract::HostCoupled}) { @@ -680,9 +669,9 @@ TEST(Distribution, GraphSuppliedRuntimeIgnoresTheRequestedContract) { in.requested = c; in.role = dist::Role::Distributable; auto m = dist::resolve(in); - EXPECT_EQ(m.unitFlags, " -nostdlib++"); + expect(eq(m.unitFlags, " -nostdlib++")); } -} +}}; // ── format_for: which format a target produces ────────────────────────────── // @@ -692,44 +681,41 @@ TEST(Distribution, GraphSuppliedRuntimeIgnoresTheRequestedContract) { // second: the canonical spellings mcpp itself uses contain neither `apple` nor // `darwin`, and the answer must not depend on the fallback. -TEST(Distribution, FormatIsTakenFromTheTargetAndNotTheFallback) { +UTest format_is_taken_from_the_target_and_not_the_fallback{"Distribution/FormatIsTakenFromTheTargetAndNotTheFallback", [] { // Every fallback, so that a target the vocabulary knows can never be // decided by the machine doing the building. for (auto fb : {dist::Format::Elf, dist::Format::MachO, dist::Format::Pe}) { - EXPECT_EQ(dist::format_for("aarch64-macos", fb), dist::Format::MachO); - EXPECT_EQ(dist::format_for("x86_64-macos", fb), dist::Format::MachO); + expect(eq(dist::format_for("aarch64-macos", fb), dist::Format::MachO)); + expect(eq(dist::format_for("x86_64-macos", fb), dist::Format::MachO)); // iOS shares macOS's Mach-O format. Before `format_for` asked // `is_mach_o()` instead of `os == "macos"`, this triple matched none // of the branches inside the parsed-triple block and fell through to // `fb` itself -- so the assertion below would have failed for two of // the three fallbacks in this loop, the exact "decided by the // machine doing the building" defect this test exists to catch. - EXPECT_EQ(dist::format_for("aarch64-ios", fb), dist::Format::MachO); - EXPECT_EQ(dist::format_for("x86_64-windows-gnu", fb), dist::Format::Pe); - EXPECT_EQ(dist::format_for("x86_64-linux-gnu", fb), dist::Format::Elf); - EXPECT_EQ(dist::format_for("aarch64-linux-musl", fb), dist::Format::Elf); - EXPECT_EQ(dist::format_for("riscv64-none-elf", fb), dist::Format::Elf); + expect(eq(dist::format_for("aarch64-ios", fb), dist::Format::MachO)); + expect(eq(dist::format_for("x86_64-windows-gnu", fb), dist::Format::Pe)); + expect(eq(dist::format_for("x86_64-linux-gnu", fb), dist::Format::Elf)); + expect(eq(dist::format_for("aarch64-linux-musl", fb), dist::Format::Elf)); + expect(eq(dist::format_for("riscv64-none-elf", fb), dist::Format::Elf)); } -} +}}; // The `[target.X]` escape hatch: a spelling the vocabulary cannot parse is all // there is to go on, so LLVM's words are recognised there. -TEST(Distribution, FormatFallsBackToSpellingForAnUnparseableTriple) { - EXPECT_EQ(dist::format_for("arm64-apple-macos14.0", dist::Format::Elf), - dist::Format::MachO); - EXPECT_EQ(dist::format_for("x86_64-w64-mingw32", dist::Format::Elf), - dist::Format::Pe); - EXPECT_EQ(dist::format_for("x86_64-unknown-darwin", dist::Format::Elf), - dist::Format::MachO); -} +UTest format_falls_back_to_spelling_for_an_unparseable_triple{"Distribution/FormatFallsBackToSpellingForAnUnparseableTriple", [] { + expect(eq(dist::format_for("arm64-apple-macos14.0", dist::Format::Elf), dist::Format::MachO)); + expect(eq(dist::format_for("x86_64-w64-mingw32", dist::Format::Elf), dist::Format::Pe)); + expect(eq(dist::format_for("x86_64-unknown-darwin", dist::Format::Elf), dist::Format::MachO)); +}}; // And only then the host. A triple that says nothing at all is the one case // where the machine doing the building is the best available answer. -TEST(Distribution, FormatUsesTheFallbackOnlyWhenTheTripleSaysNothing) { - EXPECT_EQ(dist::format_for("", dist::Format::MachO), dist::Format::MachO); - EXPECT_EQ(dist::format_for("", dist::Format::Pe), dist::Format::Pe); - EXPECT_EQ(dist::format_for("nonsense", dist::Format::Elf), dist::Format::Elf); -} +UTest format_uses_the_fallback_only_when_the_triple_says_nothing{"Distribution/FormatUsesTheFallbackOnlyWhenTheTripleSaysNothing", [] { + expect(eq(dist::format_for("", dist::Format::MachO), dist::Format::MachO)); + expect(eq(dist::format_for("", dist::Format::Pe), dist::Format::Pe)); + expect(eq(dist::format_for("nonsense", dist::Format::Elf), dist::Format::Elf)); +}}; // --------------------------------------------------------------------------- // A SECOND C++ RUNTIME ON THE LINE (mcpp#596). @@ -751,7 +737,7 @@ TEST(Distribution, FormatUsesTheFallbackOnlyWhenTheTripleSaysNothing) { // The byte-level assertions distinguish the explicit static unwinder from // libgcc in the foreign-runtime branch; neither selects a second unwinder. -TEST(Distribution, LinuxLibcxxWithAForeignRuntimeTakesOneUnwinder) { +UTest linux_libcxx_with_a_foreign_runtime_takes_one_unwinder{"Distribution/LinuxLibcxxWithAForeignRuntimeTakesOneUnwinder", [] { dist::MechanismInput in; in.format = dist::Format::Elf; in.stdlibId = "libc++"; @@ -763,27 +749,25 @@ TEST(Distribution, LinuxLibcxxWithAForeignRuntimeTakesOneUnwinder) { // The explicit archive supplies the unwinder without a second driver // selection; the foreign-runtime branch still selects libgcc instead. auto alone = dist::resolve(in); - EXPECT_EQ(alone.unitFlags, - " -nostdlib++ /tc/libc++.a /tc/libc++abi.a /tc/libunwind.a --unwindlib=none"); + expect(eq(alone.unitFlags, " -nostdlib++ /tc/libc++.a /tc/libc++abi.a /tc/libunwind.a --unwindlib=none")); in.foreignCxxRuntime = true; auto shared = dist::resolve(in); // The contract is NOT degraded: the C++ runtime is still embedded, and // libgcc_s is in the process either way because libstdc++ needs it. - EXPECT_EQ(shared.effective, dist::Contract::SelfContained); - EXPECT_FALSE(shared.degraded); - EXPECT_TRUE(shared.diagnostic.empty()); - EXPECT_EQ(shared.unitFlags, - " -nostdlib++ /tc/libc++.a /tc/libc++abi.a" + expect(eq(shared.effective, dist::Contract::SelfContained)); + expect(!shared.degraded); + expect(shared.diagnostic.empty()); + expect(eq(shared.unitFlags, " -nostdlib++ /tc/libc++.a /tc/libc++abi.a" " -Wl,--exclude-libs,libc++.a -Wl,--exclude-libs,libc++abi.a" - " --unwindlib=libgcc"); + " --unwindlib=libgcc")); // The payload's unwinder archive is NOT on the line: linking it is what // creates the second unwinder. - EXPECT_EQ(shared.unitFlags.find("libunwind.a"), std::string::npos); -} + expect(eq(shared.unitFlags.find("libunwind.a"), std::string::npos)); +}}; // A shared library retains archive privacy and uses the explicit unwinder. -TEST(Distribution, ASharedLibraryStillHidesTheArchivesWithoutASecondRuntime) { +UTest a_shared_library_still_hides_the_archives_without_a_second_runtime{"Distribution/ASharedLibraryStillHidesTheArchivesWithoutASecondRuntime", [] { dist::MechanismInput in; in.format = dist::Format::Elf; in.stdlibId = "libc++"; @@ -793,11 +777,10 @@ TEST(Distribution, ASharedLibraryStillHidesTheArchivesWithoutASecondRuntime) { in.libcxxAbiArchive = "/tc/libc++abi.a"; in.libunwindArchive = "/tc/libunwind.a"; auto m = dist::resolve(in); - EXPECT_EQ(m.unitFlags, - " -nostdlib++ /tc/libc++.a /tc/libc++abi.a" + expect(eq(m.unitFlags, " -nostdlib++ /tc/libc++.a /tc/libc++abi.a" " -Wl,--exclude-libs,libc++.a -Wl,--exclude-libs,libc++abi.a" - " /tc/libunwind.a --unwindlib=none -Wl,--exclude-libs,libunwind.a"); -} + " /tc/libunwind.a --unwindlib=none -Wl,--exclude-libs,libunwind.a")); +}}; // THE NAMES `--exclude-libs` MATCHES ARE THE ARCHIVES THE LINKER OPENS // (#634 A6). The Android NDK's per-API `libc++.a` is a linker script, @@ -805,7 +788,7 @@ TEST(Distribution, ASharedLibraryStillHidesTheArchivesWithoutASecondRuntime) { // hiding `libc++.a` alone left 161 dynamic symbols in a self-contained shared // library where naming `libc++_static.a` left 4 (measured). The paths on the // line are unchanged; only the hidden names grow, and only when given. -TEST(Distribution, ALinkerScriptsArchivesAreTheNamesHidden) { +UTest a_linker_scripts_archives_are_the_names_hidden{"Distribution/ALinkerScriptsArchivesAreTheNamesHidden", [] { dist::MechanismInput in; in.format = dist::Format::Elf; in.stdlibId = "libc++"; @@ -816,18 +799,17 @@ TEST(Distribution, ALinkerScriptsArchivesAreTheNamesHidden) { in.libunwindArchive = "/ndk/lib/clang/21/lib/linux/x86_64/libunwind.a"; in.libcxxLinkedArchiveNames = {"libc++.a", "libc++abi.a", "libc++_static.a"}; auto m = dist::resolve(in); - EXPECT_EQ(m.unitFlags, - " -nostdlib++ /ndk/sysroot/usr/lib/x86_64-linux-android/24/libc++.a" + expect(eq(m.unitFlags, " -nostdlib++ /ndk/sysroot/usr/lib/x86_64-linux-android/24/libc++.a" " /ndk/sysroot/usr/lib/x86_64-linux-android/libc++abi.a" " -Wl,--exclude-libs,libc++.a -Wl,--exclude-libs,libc++abi.a" " -Wl,--exclude-libs,libc++_static.a" " /ndk/lib/clang/21/lib/linux/x86_64/libunwind.a" - " --unwindlib=none -Wl,--exclude-libs,libunwind.a"); + " --unwindlib=none -Wl,--exclude-libs,libunwind.a")); // An executable still carries no guard, whatever the names are. in.role = dist::Role::Test; - EXPECT_EQ(dist::resolve(in).unitFlags.find("exclude-libs"), std::string::npos); -} + expect(eq(dist::resolve(in).unitFlags.find("exclude-libs"), std::string::npos)); +}}; // ─── Format::Wasm, the member the module predicted and deferred ──────────── // @@ -835,17 +817,17 @@ TEST(Distribution, ALinkerScriptsArchivesAreTheNamesHidden) { // and reaches `hostFallback`, answering the MACHINE's format -- "the same // defect class its own header measured for macOS" -- and deferred the fourth // member "to whoever gives this module a mechanism for it". -TEST(Distribution, FormatAnswersWasmForAWasmTargetAndNotTheHosts) { +UTest format_answers_wasm_for_a_wasm_target_and_not_the_hosts{"Distribution/FormatAnswersWasmForAWasmTargetAndNotTheHosts", [] { using mcpp::build::dist::Format; using mcpp::build::dist::format_for; // Every host fallback, so this cannot pass by agreeing with the machine. for (auto fallback : {Format::Elf, Format::MachO, Format::Pe}) - EXPECT_EQ(format_for("wasm32-emscripten", fallback), Format::Wasm); + expect(eq(format_for("wasm32-emscripten", fallback), Format::Wasm)); // The three it already answered stay unchanged. - EXPECT_EQ(format_for("x86_64-linux-gnu", Format::Pe), Format::Elf); - EXPECT_EQ(format_for("aarch64-macos", Format::Elf), Format::MachO); - EXPECT_EQ(format_for("x86_64-windows-gnu", Format::Elf), Format::Pe); -} + expect(eq(format_for("x86_64-linux-gnu", Format::Pe), Format::Elf)); + expect(eq(format_for("aarch64-macos", Format::Elf), Format::MachO)); + expect(eq(format_for("x86_64-windows-gnu", Format::Elf), Format::Pe)); +}}; // THERE IS NOTHING TO BE COUPLED TO, so the contract is satisfied with // nothing added and there is no degradation to report. @@ -854,21 +836,21 @@ TEST(Distribution, FormatAnswersWasmForAWasmTargetAndNotTheHosts) { // `libc++.so` that cannot exist for the target, on an artefact that has no // run-time dependency of any kind. A diagnostic is for a BROKEN PROMISE; that // one was a promise about a mechanism the format does not have. -TEST(Distribution, WasmIsSelfContainedByConstructionAndSaysNothing) { +UTest wasm_is_self_contained_by_construction_and_says_nothing{"Distribution/WasmIsSelfContainedByConstructionAndSaysNothing", [] { mcpp::build::dist::MechanismInput in; in.format = mcpp::build::dist::Format::Wasm; in.stdlibId = "libc++"; in.requested = mcpp::build::dist::Contract::SelfContained; in.role = mcpp::build::dist::Role::Distributable; auto m = mcpp::build::dist::resolve(in); - EXPECT_EQ(m.effective, mcpp::build::dist::Contract::SelfContained); - EXPECT_FALSE(m.degraded); - EXPECT_TRUE(m.diagnostic.empty()) << m.diagnostic; + expect(eq(m.effective, mcpp::build::dist::Contract::SelfContained)); + expect(!m.degraded); + expect(m.diagnostic.empty()) << m.diagnostic; // And no flags: libc++ reaches a wasm link through `em++`'s own link line, // so naming archives from a sysroot this module did not resolve would be a // second answer to a question the driver has already answered. - EXPECT_TRUE(m.unitFlags.empty()) << m.unitFlags; -} + expect(m.unitFlags.empty()) << m.unitFlags; +}}; // --------------------------------------------------------------------------- // #646 F3a -- one process, one C++ runtime. @@ -878,77 +860,77 @@ TEST(Distribution, WasmIsSelfContainedByConstructionAndSaysNothing) { // held two C++ runtimes, and with llvm@22.1.8 it aborted with std::bad_cast. // An unstated program or test contract takes the shared library's when its // image loads a C++ shared library of the build. -TEST(Distribution, AProgramThatLoadsACxxSharedLibraryTakesItsContractOnElf) { +UTest a_program_that_loads_a_cxx_shared_library_takes_its_contract_on_elf{"Distribution/AProgramThatLoadsACxxSharedLibraryTakesItsContractOnElf", [] { const dist::ContractStatement nothing{}; auto c = dist::role_contracts(nothing, dist::Format::Elf, {.program = true, .tests = true}); - EXPECT_EQ(c.shared, dist::Contract::ToolchainCoupled); - EXPECT_EQ(c.program, dist::Contract::ToolchainCoupled); - EXPECT_EQ(c.tests, dist::Contract::ToolchainCoupled); + expect(eq(c.shared, dist::Contract::ToolchainCoupled)); + expect(eq(c.program, dist::Contract::ToolchainCoupled)); + expect(eq(c.tests, dist::Contract::ToolchainCoupled)); // The intermediate role links no runtime and keeps the program default. - EXPECT_EQ(c.intermediate, dist::Contract::SelfContained); - EXPECT_FALSE(c.programStated); - EXPECT_FALSE(dist::runtime_split(c, dist::Format::Elf, - {.program = true, .tests = true}).has_value()); -} + expect(eq(c.intermediate, dist::Contract::SelfContained)); + expect(!c.programStated); + expect(!(dist::runtime_split(c, dist::Format::Elf, + {.program = true, .tests = true}).has_value())); +}}; // Nothing moves for a program that loads no C++ shared library: the default // that makes a shipped binary portable is untouched. -TEST(Distribution, AProgramThatLoadsNoCxxSharedLibraryKeepsItsDefault) { +UTest a_program_that_loads_no_cxx_shared_library_keeps_its_default{"Distribution/AProgramThatLoadsNoCxxSharedLibraryKeepsItsDefault", [] { auto c = dist::role_contracts({}, dist::Format::Elf, {}); - EXPECT_EQ(c.program, dist::Contract::SelfContained); - EXPECT_EQ(c.tests, dist::Contract::SelfContained); + expect(eq(c.program, dist::Contract::SelfContained)); + expect(eq(c.tests, dist::Contract::SelfContained)); auto onlyTests = dist::role_contracts({}, dist::Format::Elf, {.tests = true}); - EXPECT_EQ(onlyTests.program, dist::Contract::SelfContained); - EXPECT_EQ(onlyTests.tests, dist::Contract::ToolchainCoupled); -} + expect(eq(onlyTests.program, dist::Contract::SelfContained)); + expect(eq(onlyTests.tests, dist::Contract::ToolchainCoupled)); +}}; // Mach-O and PE keep their table: Mach-O's shared default is already // self-contained (the F2 question is measured separately), and PE resolves // imports per DLL. -TEST(Distribution, TheFollowingRuleIsElfOnly) { +UTest the_following_rule_is_elf_only{"Distribution/TheFollowingRuleIsElfOnly", [] { for (auto f : {dist::Format::MachO, dist::Format::Pe, dist::Format::Wasm}) { auto c = dist::role_contracts({}, f, {.program = true, .tests = true}); - EXPECT_EQ(c.program, dist::Contract::SelfContained); - EXPECT_FALSE(dist::runtime_split(c, f, {.program = true}).has_value()); + expect(eq(c.program, dist::Contract::SelfContained)); + expect(!(dist::runtime_split(c, f, {.program = true}).has_value())); } -} +}}; // A STATED contract is never moved. A stated self-contained program over a // coupled C++ shared library is the split, and is named for the caller to // refuse; a stated self-contained project (the library self-contained too) is // the documented private-copy arrangement and is not. -TEST(Distribution, AStatedSelfContainedProgramOverACoupledLibraryIsASplit) { +UTest a_stated_self_contained_program_over_a_coupled_library_is_a_split{"Distribution/AStatedSelfContainedProgramOverACoupledLibraryIsASplit", [] { const dist::ContractStatement split{ .cxxRuntime = "self-contained", .cxxRuntimeShared = "toolchain-coupled"}; auto c = dist::role_contracts(split, dist::Format::Elf, {.program = true}); - EXPECT_EQ(c.program, dist::Contract::SelfContained); - EXPECT_TRUE(c.programStated); + expect(eq(c.program, dist::Contract::SelfContained)); + expect(bool(c.programStated)); auto role = dist::runtime_split(c, dist::Format::Elf, {.program = true}); - ASSERT_TRUE(role.has_value()); - EXPECT_EQ(*role, dist::Role::Distributable); + expect(fatal(role.has_value())); + expect(eq(*role, dist::Role::Distributable)); const dist::ContractStatement whole{.cxxRuntime = "self-contained"}; auto w = dist::role_contracts(whole, dist::Format::Elf, {.program = true}); - EXPECT_EQ(w.shared, dist::Contract::SelfContained); - EXPECT_FALSE(dist::runtime_split(w, dist::Format::Elf, {.program = true}).has_value()); + expect(eq(w.shared, dist::Contract::SelfContained)); + expect(!(dist::runtime_split(w, dist::Format::Elf, {.program = true}).has_value())); const dist::ContractStatement tests{.cxxRuntimeTests = "self-contained"}; auto t = dist::role_contracts(tests, dist::Format::Elf, {.tests = true}); auto testRole = dist::runtime_split(t, dist::Format::Elf, {.tests = true}); - ASSERT_TRUE(testRole.has_value()); - EXPECT_EQ(*testRole, dist::Role::Test); -} + expect(fatal(testRole.has_value())); + expect(eq(*testRole, dist::Role::Test)); +}}; // `static_stdlib = false` is a statement for every role: all of them are // host-coupled and nothing splits. -TEST(Distribution, TheOlderSpellingStatesEveryRole) { +UTest the_older_spelling_states_every_role{"Distribution/TheOlderSpellingStatesEveryRole", [] { const dist::ContractStatement host{.staticStdlib = false}; auto c = dist::role_contracts(host, dist::Format::Elf, {.program = true, .tests = true}); - EXPECT_EQ(c.program, dist::Contract::HostCoupled); - EXPECT_EQ(c.shared, dist::Contract::HostCoupled); - EXPECT_FALSE(dist::runtime_split(c, dist::Format::Elf, {.program = true}).has_value()); -} + expect(eq(c.program, dist::Contract::HostCoupled)); + expect(eq(c.shared, dist::Contract::HostCoupled)); + expect(!(dist::runtime_split(c, dist::Format::Elf, {.program = true}).has_value())); +}}; // --------------------------------------------------------------------------- // #649 E10, closed by #718. Clang on the MSVC ABI used to be given no CRT @@ -963,7 +945,7 @@ TEST(Distribution, TheOlderSpellingStatesEveryRole) { // explicit `toolchain-coupled` (prepare/plan.cpp), not a table cell, and an // undeclared one is resolved to `host-coupled` before this table ever runs // (`dist::msvc_abi_default_contract`). -TEST(Distribution, NoMechanismCellDistinguishesClangFromClOnTheMsvcAbi) { +UTest no_mechanism_cell_distinguishes_clang_from_cl_on_the_msvc_abi{"Distribution/NoMechanismCellDistinguishesClangFromClOnTheMsvcAbi", [] { dist::MechanismInput in; in.format = dist::Format::Pe; in.stdlibId = "msvc"; @@ -973,25 +955,25 @@ TEST(Distribution, NoMechanismCellDistinguishesClangFromClOnTheMsvcAbi) { in.explicitRequest = true; in.requested = dist::Contract::ToolchainCoupled; auto coupled = dist::resolve(in); - EXPECT_EQ(coupled.effective, dist::Contract::ToolchainCoupled); - EXPECT_TRUE(coupled.deployToolchainRuntime); - EXPECT_FALSE(coupled.degraded); - EXPECT_TRUE(coupled.diagnostic.empty()); + expect(eq(coupled.effective, dist::Contract::ToolchainCoupled)); + expect(bool(coupled.deployToolchainRuntime)); + expect(!coupled.degraded); + expect(coupled.diagnostic.empty()); // /MT, requesting (and delivering) the static CRT. in.msvcStaticCrt = true; in.requested = dist::Contract::SelfContained; auto selfContained = dist::resolve(in); - EXPECT_EQ(selfContained.effective, dist::Contract::SelfContained); - EXPECT_FALSE(selfContained.degraded); - EXPECT_TRUE(selfContained.diagnostic.empty()); + expect(eq(selfContained.effective, dist::Contract::SelfContained)); + expect(!selfContained.degraded); + expect(selfContained.diagnostic.empty()); // The undeclared default: quiet, whichever way `msvcStaticCrt` reads. in.explicitRequest = false; in.msvcStaticCrt = false; in.requested = dist::Contract::HostCoupled; auto quiet = dist::resolve(in); - EXPECT_EQ(quiet.effective, dist::Contract::HostCoupled); - EXPECT_FALSE(quiet.degraded); - EXPECT_TRUE(quiet.diagnostic.empty()); -} + expect(eq(quiet.effective, dist::Contract::HostCoupled)); + expect(!quiet.degraded); + expect(quiet.diagnostic.empty()); +}}; diff --git a/tests/unit/test_doctor_fixincludes.cpp b/tests/unit/test_doctor_fixincludes.cpp index 627c83eb8..d9bfbc6e3 100644 --- a/tests/unit/test_doctor_fixincludes.cpp +++ b/tests/unit/test_doctor_fixincludes.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.doctor; +using namespace mcpplibs::mtest; + using mcpp::doctor::find_fixincludes_banners; // mcpp#687: GCC's `fixincludes` step writes a frozen copy of the BUILD @@ -54,67 +56,66 @@ constexpr std::string_view kReadmeFixture = } // namespace -TEST(DoctorFixincludes, ABannerFileIsReportedWithItsSource) { +UTest a_banner_file_is_reported_with_its_source{"DoctorFixincludes/ABannerFileIsReportedWithItsSource", [] { auto root = make_tempdir("mcpp-fixincludes-banner"); write_file(root / "lib" / "gcc" / "x86_64-linux-gnu" / "13.3.0" / "include-fixed" / "pthread.h", kBannerFixture); auto findings = find_fixincludes_banners(root); - ASSERT_EQ(findings.size(), 1u); - EXPECT_EQ(findings[0].file, fs::path("lib") / "gcc" / "x86_64-linux-gnu" - / "13.3.0" / "include-fixed" / "pthread.h"); - EXPECT_EQ(findings[0].source, - "/home/xlings/.xlings_data/subos/linux/usr/include/pthread.h"); + expect(fatal(eq(findings.size(), 1u))); + expect(eq(findings[0].file, fs::path("lib") / "gcc" / "x86_64-linux-gnu" + / "13.3.0" / "include-fixed" / "pthread.h")); + expect(eq(findings[0].source, "/home/xlings/.xlings_data/subos/linux/usr/include/pthread.h")); fs::remove_all(root); -} +}}; // The exact 16.1.0 shape: an `include-fixed` directory that exists (fixincludes // always creates it) but holds only its own README, no fixed header at all. -TEST(DoctorFixincludes, AReadmeWithNoBannerIsNotReported) { +UTest a_readme_with_no_banner_is_not_reported{"DoctorFixincludes/AReadmeWithNoBannerIsNotReported", [] { auto root = make_tempdir("mcpp-fixincludes-readme-only"); write_file(root / "lib" / "gcc" / "x86_64-linux-gnu" / "16.1.0" / "include-fixed" / "README", kReadmeFixture); - EXPECT_TRUE(find_fixincludes_banners(root).empty()); + expect(find_fixincludes_banners(root).empty()); fs::remove_all(root); -} +}}; // A fixed header that carries no banner (some fixincludes edits do not add // one — e.g. a bare wrapped `limits.h`) is not a match either: the check is // specifically for the auto-edited-FROM-a-source-path shape mcpp#687 is // about, not for "any file under include-fixed". -TEST(DoctorFixincludes, AFixedHeaderWithoutTheBannerIsNotReported) { +UTest a_fixed_header_without_the_banner_is_not_reported{"DoctorFixincludes/AFixedHeaderWithoutTheBannerIsNotReported", [] { auto root = make_tempdir("mcpp-fixincludes-no-banner"); write_file(root / "lib" / "gcc" / "x86_64-linux-gnu" / "13.3.0" / "include-fixed" / "limits.h", "/* -*- Mode: C -*- */\n#ifndef _GCC_LIMITS_H_\n"); - EXPECT_TRUE(find_fixincludes_banners(root).empty()); + expect(find_fixincludes_banners(root).empty()); fs::remove_all(root); -} +}}; // A file that carries the banner text but sits OUTSIDE any `include-fixed` // directory does not count — the check is scoped to the one directory // fixincludes actually writes into, not to the whole payload tree. -TEST(DoctorFixincludes, ABannerOutsideIncludeFixedIsNotReported) { +UTest a_banner_outside_include_fixed_is_not_reported{"DoctorFixincludes/ABannerOutsideIncludeFixedIsNotReported", [] { auto root = make_tempdir("mcpp-fixincludes-outside"); write_file(root / "lib" / "gcc" / "x86_64-linux-gnu" / "13.3.0" / "include" / "pthread.h", kBannerFixture); - EXPECT_TRUE(find_fixincludes_banners(root).empty()); + expect(find_fixincludes_banners(root).empty()); fs::remove_all(root); -} +}}; // Every `include-fixed` directory under the payload root is scanned, not // only the first one found — a payload that ships more than one fixed // header (the common case: pthread.h plus others) is reported in full, and // two DIFFERENT triples under the same payload root (a cross payload's own // shape) are both covered by one call. -TEST(DoctorFixincludes, MultipleFindingsAreAllReported) { +UTest multiple_findings_are_all_reported{"DoctorFixincludes/MultipleFindingsAreAllReported", [] { auto root = make_tempdir("mcpp-fixincludes-multi"); write_file(root / "lib" / "gcc" / "x86_64-linux-gnu" / "13.3.0" / "include-fixed" / "pthread.h", kBannerFixture); @@ -122,14 +123,14 @@ TEST(DoctorFixincludes, MultipleFindingsAreAllReported) { / "include-fixed" / "stdio.h", kBannerFixture); auto findings = find_fixincludes_banners(root); - EXPECT_EQ(findings.size(), 2u); + expect(eq(findings.size(), 2u)); fs::remove_all(root); -} +}}; // A payload root that does not exist at all (nothing installed under that // name/version) is not an error — it is the same as "nothing to report". -TEST(DoctorFixincludes, ANonexistentPayloadRootReportsNothing) { - EXPECT_TRUE(find_fixincludes_banners( +UTest a_nonexistent_payload_root_reports_nothing{"DoctorFixincludes/ANonexistentPayloadRootReportsNothing", [] { + expect(find_fixincludes_banners( fs::temp_directory_path() / "mcpp-fixincludes-does-not-exist").empty()); -} +}}; diff --git a/tests/unit/test_doctor_runpath.cpp b/tests/unit/test_doctor_runpath.cpp index 54b1adae6..b51585c6a 100644 --- a/tests/unit/test_doctor_runpath.cpp +++ b/tests/unit/test_doctor_runpath.cpp @@ -1,13 +1,15 @@ -#include import std; +import mcpplibs.mtest; import mcpp.doctor; +using namespace mcpplibs::mtest; + using mcpp::doctor::parse_readelf_runpath; // `readelf -d` line shape (the case that motivated the check): clang++ with a // RUNPATH that includes a now-removed xim-x-zlib lib dir. -TEST(DoctorRunpath, ParsesRunpathColonSeparatedDirs) { +UTest parses_runpath_colon_separated_dirs{"DoctorRunpath/ParsesRunpathColonSeparatedDirs", [] { std::string dump = " 0x0000000000000001 (NEEDED) Shared library: [libz.so.1]\n" " 0x000000000000001d (RUNPATH) Library runpath: " @@ -17,37 +19,37 @@ TEST(DoctorRunpath, ParsesRunpathColonSeparatedDirs) { " 0x000000000000000c (INIT) 0x1000\n"; auto dirs = parse_readelf_runpath(dump); - ASSERT_EQ(dirs.size(), 3u); - EXPECT_EQ(dirs[0], "/home/u/.mcpp/data/xpkgs/xim-x-llvm/20.1.7/lib"); - EXPECT_EQ(dirs[1], "/home/u/.mcpp/data/xpkgs/xim-x-zlib/1.3.1/lib"); - EXPECT_EQ(dirs[2], "/home/u/.mcpp/registry/subos/default/lib"); -} + expect(fatal(eq(dirs.size(), 3u))); + expect(eq(dirs[0], "/home/u/.mcpp/data/xpkgs/xim-x-llvm/20.1.7/lib")); + expect(eq(dirs[1], "/home/u/.mcpp/data/xpkgs/xim-x-zlib/1.3.1/lib")); + expect(eq(dirs[2], "/home/u/.mcpp/registry/subos/default/lib")); +}}; // DT_RPATH (legacy) is parsed the same way as DT_RUNPATH. -TEST(DoctorRunpath, ParsesLegacyRpath) { +UTest parses_legacy_rpath{"DoctorRunpath/ParsesLegacyRpath", [] { std::string dump = " 0x000000000000000f (RPATH) Library rpath: [/opt/a/lib:/opt/b/lib]\n"; auto dirs = parse_readelf_runpath(dump); - ASSERT_EQ(dirs.size(), 2u); - EXPECT_EQ(dirs[0], "/opt/a/lib"); - EXPECT_EQ(dirs[1], "/opt/b/lib"); -} + expect(fatal(eq(dirs.size(), 2u))); + expect(eq(dirs[0], "/opt/a/lib")); + expect(eq(dirs[1], "/opt/b/lib")); +}}; // A binary with no RUNPATH/RPATH entry yields no dirs. -TEST(DoctorRunpath, NoRunpathYieldsEmpty) { +UTest no_runpath_yields_empty{"DoctorRunpath/NoRunpathYieldsEmpty", [] { std::string dump = " 0x0000000000000001 (NEEDED) Shared library: [libc.so.6]\n" " 0x000000000000000c (INIT) 0x1000\n"; - EXPECT_TRUE(parse_readelf_runpath(dump).empty()); -} + expect(parse_readelf_runpath(dump).empty()); +}}; // Empty path tokens (e.g. a trailing ':') are dropped, not reported as a // missing dir. -TEST(DoctorRunpath, DropsEmptyTokens) { +UTest drops_empty_tokens{"DoctorRunpath/DropsEmptyTokens", [] { std::string dump = " 0x000000000000001d (RUNPATH) Library runpath: [/a/lib::/b/lib:]\n"; auto dirs = parse_readelf_runpath(dump); - ASSERT_EQ(dirs.size(), 2u); - EXPECT_EQ(dirs[0], "/a/lib"); - EXPECT_EQ(dirs[1], "/b/lib"); -} + expect(fatal(eq(dirs.size(), 2u))); + expect(eq(dirs[0], "/a/lib")); + expect(eq(dirs[1], "/b/lib")); +}}; diff --git a/tests/unit/test_dots_screen.cpp b/tests/unit/test_dots_screen.cpp index f1bf2159c..dc0bdf031 100644 --- a/tests/unit/test_dots_screen.cpp +++ b/tests/unit/test_dots_screen.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.ui.dots_screen; +using namespace mcpplibs::mtest; + // The status row's screen and its animations // (.agents/docs/2026-09-30-build-output-refinement-design.md, §5.9 to §5.13): // 24 braille cells, a colour per cell, and four animations that take their @@ -22,7 +24,7 @@ std::vector cells(std::string_view s) { ++i; continue; } - EXPECT_EQ(b & 0xF0, 0xE0) << "not a three-byte code point at " << i; + expect(eq(b & 0xF0, 0xE0)) << "not a three-byte code point at " << i; const char32_t c = (static_cast(b & 0x0F) << 12) | (static_cast(s[i + 1] & 0x3F) << 6) | static_cast(s[i + 2] & 0x3F); @@ -53,63 +55,63 @@ std::string play(Animation& a, int frames, std::size_t perFrame, bool failAt = f } // namespace -TEST(DotsScreen, TheFourAnimationsAreBuiltIn) { +UTest the_four_animations_are_built_in{"DotsScreen/TheFourAnimationsAreBuiltIn", [] { auto names = mcpp::ui::dots_screen::names(); - ASSERT_EQ(names.size(), 4u); - EXPECT_EQ(names[0], "chomp"); - EXPECT_EQ(names[1], "snake"); - EXPECT_EQ(names[2], "stack"); - EXPECT_EQ(names[3], "ions"); - for (auto n : names) EXPECT_NE(make(n, 1), nullptr) << n; - EXPECT_EQ(make("runner", 1), nullptr); - EXPECT_EQ(make("", 1), nullptr); -} + expect(fatal(eq(names.size(), 4u))); + expect(eq(names[0], "chomp")); + expect(eq(names[1], "snake")); + expect(eq(names[2], "stack")); + expect(eq(names[3], "ions")); + for (auto n : names) expect(ne(make(n, 1), nullptr)) << n; + expect(eq(make("runner", 1), nullptr)); + expect(eq(make("", 1), nullptr)); +}}; -TEST(DotsScreen, TheScreenIsTwentyFourBrailleCells) { +UTest the_screen_is_twenty_four_braille_cells{"DotsScreen/TheScreenIsTwentyFourBrailleCells", [] { Screen sc; sc.set(0, 0, Colour::White); // the first dot of the first cell sc.set(47, 3, Colour::Red); // the last dot of the last cell sc.set(99, 0, Colour::White); // off the screen: ignored const auto text = sc.render(false); auto c = cells(text); - ASSERT_EQ(c.size(), 24u); - for (auto cp : c) EXPECT_TRUE(cp >= 0x2800 && cp <= 0x28FF); - EXPECT_EQ(c.front(), char32_t{0x2801}); - EXPECT_EQ(c.back(), char32_t{0x2880}); - EXPECT_EQ(text.find('\x1b'), std::string::npos); + expect(fatal(eq(c.size(), 24u))); + for (auto cp : c) expect(cp >= 0x2800 && cp <= 0x28FF); + expect(eq(c.front(), char32_t{0x2801})); + expect(eq(c.back(), char32_t{0x2880})); + expect(eq(text.find('\x1b'), std::string::npos)); const auto coloured = sc.render(true); - EXPECT_NE(coloured.find("\x1b[91m"), std::string::npos); - EXPECT_TRUE(coloured.ends_with("\x1b[0m")); -} + expect(ne(coloured.find("\x1b[91m"), std::string::npos)); + expect(coloured.ends_with("\x1b[0m")); +}}; -TEST(DotsScreen, AnAnimationIsPureGivenItsSeedAndInputs) { +UTest an_animation_is_pure_given_its_seed_and_inputs{"DotsScreen/AnAnimationIsPureGivenItsSeedAndInputs", [] { for (auto n : mcpp::ui::dots_screen::names()) { auto a = make(n, 42), b = make(n, 42); - EXPECT_EQ(play(*a, 60, 5), play(*b, 60, 5)) << n; + expect(eq(play(*a, 60, 5), play(*b, 60, 5))) << n; } -} +}}; -TEST(DotsScreen, EveryFrameIsTwentyFourCellsAndAFailureTurnsRed) { +UTest every_frame_is_twenty_four_cells_and_a_failure_turns_red{"DotsScreen/EveryFrameIsTwentyFourCellsAndAFailureTurnsRed", [] { for (auto n : mcpp::ui::dots_screen::names()) { auto a = make(n, 7); const auto plain = play(*a, 40, 3); - EXPECT_EQ(cells(plain).size(), 24u) << n; - EXPECT_EQ(plain.find('\n'), std::string::npos) << n; + expect(eq(cells(plain).size(), 24u)) << n; + expect(eq(plain.find('\n'), std::string::npos)) << n; auto f = make(n, 7); const auto failed = play(*f, 40, 3, /*failAt=*/true, /*colour=*/true); - EXPECT_NE(failed.find("\x1b[91m"), std::string::npos) << n; + expect(ne(failed.find("\x1b[91m"), std::string::npos)) << n; } -} +}}; -TEST(DotsScreen, TheSnakeCarriesTheColourOfThePackagesItAte) { +UTest the_snake_carries_the_colour_of_the_packages_it_ate{"DotsScreen/TheSnakeCarriesTheColourOfThePackagesItAte", [] { auto a = make("snake", 3); a->package(Source::Official); a->package(Source::Official); const auto frame = play(*a, 200, 2, false, /*colour=*/true); - EXPECT_NE(frame.find("\x1b[36m"), std::string::npos) << "no segment in the official colour"; -} + expect(ne(frame.find("\x1b[36m"), std::string::npos)) << "no segment in the official colour"; +}}; -TEST(DotsScreen, TheIonsDepositIsTheFraction) { +UTest the_ions_deposit_is_the_fraction{"DotsScreen/TheIonsDepositIsTheFraction", [] { auto a = make("ions", 5); Input in; in.dt = 0.1; @@ -121,10 +123,10 @@ TEST(DotsScreen, TheIonsDepositIsTheFraction) { for (int x = 0; x < kWidth - 4; ++x) for (int y = 0; y < kHeight; ++y) if (sc.at(x, y) != Colour::None) ++lit; - EXPECT_EQ(lit, (kWidth - 4) * kHeight / 2); -} + expect(eq(lit, (kWidth - 4) * kHeight / 2)); +}}; -TEST(DotsScreen, TheChomperStandsAtTheFraction) { +UTest the_chomper_stands_at_the_fraction{"DotsScreen/TheChomperStandsAtTheFraction", [] { auto a = make("chomp", 1); Input in; in.fraction = 1.0; @@ -135,9 +137,9 @@ TEST(DotsScreen, TheChomperStandsAtTheFraction) { bool pellet = false; for (int x = kWidth - 1 - 0; x < kWidth; ++x) if (sc.at(x, 2) == Colour::Grey) pellet = true; - EXPECT_FALSE(pellet); - EXPECT_EQ(sc.at(kWidth - 5, 1), Colour::Yellow); -} + expect(!pellet); + expect(eq(sc.at(kWidth - 5, 1), Colour::Yellow)); +}}; // ─── Every frame returns (build wall-time plan, F1 and W1) ────────────── // @@ -159,7 +161,7 @@ void returns_within(std::chrono::seconds limit, std::string what, done->set_value(); }).detach(); if (finished.wait_for(limit) != std::future_status::ready) { - ADD_FAILURE() << what << " did not return within " << limit.count() << " s"; + expect(false) << what << " did not return within " << limit.count() << " s"; std::fflush(nullptr); std::_Exit(1); } @@ -187,7 +189,7 @@ void play_build(Animation& a, std::uint64_t seed, std::size_t total, int tail) { } // namespace -TEST(DotsScreen, EveryAnimationReturnsFromEveryFrame) { +UTest every_animation_returns_from_every_frame{"DotsScreen/EveryAnimationReturnsFromEveryFrame", [] { for (auto name : mcpp::ui::dots_screen::names()) { const std::string n(name); returns_within(std::chrono::seconds(120), "an animation '" + n + "'", [n] { @@ -197,9 +199,9 @@ TEST(DotsScreen, EveryAnimationReturnsFromEveryFrame) { } }); } -} +}}; -TEST(DotsScreenGames, EveryGameReturnsFromEveryFrame) { +UTest every_game_returns_from_every_frame{"DotsScreenGames/EveryGameReturnsFromEveryFrame", [] { for (auto name : game_names()) { const std::string n(name); returns_within(std::chrono::seconds(120), "a game '" + n + "'", [n] { @@ -217,7 +219,7 @@ TEST(DotsScreenGames, EveryGameReturnsFromEveryFrame) { } }); } -} +}}; // ─── The games of --play-game (design §5.14) ───────────────────────────── @@ -237,28 +239,28 @@ void run(Game& g, double seconds) { } } // namespace -TEST(DotsScreenGames, TheThreeGamesAreBuiltIn) { +UTest the_three_games_are_built_in{"DotsScreenGames/TheThreeGamesAreBuiltIn", [] { auto names = game_names(); - ASSERT_EQ(names.size(), 3u); - EXPECT_EQ(names[0], "snake"); - EXPECT_EQ(names[1], "stack"); - EXPECT_EQ(names[2], "runner"); - for (auto n : names) EXPECT_NE(make_game(n, 1), nullptr) << n; - EXPECT_EQ(make_game("chomp", 1), nullptr); // an animation, not a game -} + expect(fatal(eq(names.size(), 3u))); + expect(eq(names[0], "snake")); + expect(eq(names[1], "stack")); + expect(eq(names[2], "runner")); + for (auto n : names) expect(ne(make_game(n, 1), nullptr)) << n; + expect(eq(make_game("chomp", 1), nullptr)); // an animation, not a game +}}; -TEST(DotsScreenGames, TheSnakeTurnsWithTheArrowsAndAWallEndsTheRound) { +UTest the_snake_turns_with_the_arrows_and_a_wall_ends_the_round{"DotsScreenGames/TheSnakeTurnsWithTheArrowsAndAWallEndsTheRound", [] { auto g = make_game("snake", 4); - EXPECT_FALSE(any_red(*g)); + expect(!any_red(*g)); g->key(Key::Up); // from row 1: row 0, then the wall run(*g, 0.3); - EXPECT_TRUE(any_red(*g)) << "the snake went through the wall"; + expect(bool(any_red(*g))) << "the snake went through the wall"; run(*g, 1.2); // a new round starts after a second - EXPECT_FALSE(any_red(*g)); - EXPECT_EQ(g->score(), 0); -} + expect(!any_red(*g)); + expect(eq(g->score(), 0)); +}}; -TEST(DotsScreenGames, TheStackPlacesPiecesAgainstTheLeftAndScoresClearedColumns) { +UTest the_stack_places_pieces_against_the_left_and_scores_cleared_columns{"DotsScreenGames/TheStackPlacesPiecesAgainstTheLeftAndScoresClearedColumns", [] { auto g = make_game("stack", 9); for (int i = 0; i < 400 && g->score() == 0; ++i) { // Spread the pieces over the four rows so that columns fill. @@ -270,18 +272,18 @@ TEST(DotsScreenGames, TheStackPlacesPiecesAgainstTheLeftAndScoresClearedColumns) g->draw(sc); bool left = false; for (int y = 0; y < kHeight; ++y) left = left || sc.at(0, y) != Colour::None; - EXPECT_TRUE(left) << "no piece rests against the left edge"; - EXPECT_GE(g->best(), g->score()); -} + expect(bool(left)) << "no piece rests against the left edge"; + expect(ge(g->best(), g->score())); +}}; -TEST(DotsScreenGames, TheRunnerJumpsOnSpaceAndACactusEndsTheRound) { +UTest the_runner_jumps_on_space_and_a_cactus_ends_the_round{"DotsScreenGames/TheRunnerJumpsOnSpaceAndACactusEndsTheRound", [] { auto g = make_game("runner", 2); // Without a key, a cactus reaches the runner within a few seconds. bool ended = false; Input in; in.dt = 0.05; for (double t = 0; t < 6 && !ended; t += in.dt) { g->update(in); ended = any_red(*g); } - EXPECT_TRUE(ended) << "no cactus ever touched the runner"; + expect(bool(ended)) << "no cactus ever touched the runner"; run(*g, 1.1); // Space lifts it: its head rises above row 1. g->key(Key::Space); @@ -290,5 +292,5 @@ TEST(DotsScreenGames, TheRunnerJumpsOnSpaceAndACactusEndsTheRound) { g->draw(sc); bool high = false; for (int x = 3; x < 7; ++x) high = high || sc.at(x, 0) == Colour::White; - EXPECT_TRUE(high) << "the runner did not jump"; -} + expect(bool(high)) << "the runner did not jump"; +}}; diff --git a/tests/unit/test_dyndep.cpp b/tests/unit/test_dyndep.cpp index 1a9280e12..997c2627d 100644 --- a/tests/unit/test_dyndep.cpp +++ b/tests/unit/test_dyndep.cpp @@ -1,18 +1,20 @@ -#include import std; +import mcpplibs.mtest; import mcpp.dyndep; +using namespace mcpplibs::mtest; + using namespace mcpp::dyndep; -TEST(Dyndep, BmiBasenameSanitizes) { - EXPECT_EQ(bmi_basename("foo"), "foo.gcm"); - EXPECT_EQ(bmi_basename("foo.bar"), "foo.bar.gcm"); - EXPECT_EQ(bmi_basename("foo:impl"), "foo-impl.gcm"); - EXPECT_EQ(bmi_basename("a.b:c"), "a.b-c.gcm"); -} +UTest bmi_basename_sanitizes{"Dyndep/BmiBasenameSanitizes", [] { + expect(eq(bmi_basename("foo"), "foo.gcm")); + expect(eq(bmi_basename("foo.bar"), "foo.bar.gcm")); + expect(eq(bmi_basename("foo:impl"), "foo-impl.gcm")); + expect(eq(bmi_basename("a.b:c"), "a.b-c.gcm")); +}}; -TEST(Dyndep, ParseDdiPickProvidesRequires) { +UTest parse_ddi_pick_provides_requires{"Dyndep/ParseDdiPickProvidesRequires", [] { constexpr const char* body = R"({ "rules": [ { @@ -23,16 +25,16 @@ TEST(Dyndep, ParseDdiPickProvidesRequires) { ] })"; auto u = parse_ddi(body); - ASSERT_TRUE(u) << u.error(); - EXPECT_EQ(u->primaryOutput.string(), "obj/foo.m.o"); - ASSERT_EQ(u->provides.size(), 1u); - EXPECT_EQ(u->provides[0], "foo"); - ASSERT_EQ(u->requires_.size(), 2u); - EXPECT_EQ(u->requires_[0], "std"); - EXPECT_EQ(u->requires_[1], "foo:impl"); -} - -TEST(Dyndep, EmitDyndepBasic) { + if (auto&& failed = expect(fatal(bool(u))); !failed) failed << u.error(); + expect(eq(u->primaryOutput.string(), "obj/foo.m.o")); + expect(fatal(eq(u->provides.size(), 1u))); + expect(eq(u->provides[0], "foo")); + expect(fatal(eq(u->requires_.size(), 2u))); + expect(eq(u->requires_[0], "std")); + expect(eq(u->requires_[1], "foo:impl")); +}}; + +UTest emit_dyndep_basic{"Dyndep/EmitDyndepBasic", [] { std::vector units = { { "obj/lib-greet.m.o", {"myapp.lib:greet"}, {"std"} }, { "obj/lib.m.o", {"myapp.lib"}, {"myapp.lib:greet", "std"} }, @@ -41,44 +43,42 @@ TEST(Dyndep, EmitDyndepBasic) { auto body = emit_dyndep(units, /*stdImports=*/{}); // Header - EXPECT_NE(body.find("ninja_dyndep_version = 1"), std::string::npos); + expect(ne(body.find("ninja_dyndep_version = 1"), std::string::npos)); // Entry per unit, with implicit inputs only (no implicit outputs — those // are declared statically in build.ninja). - EXPECT_NE(body.find( - "build obj/lib-greet.m.o: dyndep | gcm.cache/std.gcm"), std::string::npos); - EXPECT_NE(body.find( - "build obj/lib.m.o: dyndep | gcm.cache/myapp.lib-greet.gcm gcm.cache/std.gcm"), - std::string::npos); - EXPECT_NE(body.find( - "build obj/main.o: dyndep | gcm.cache/myapp.lib.gcm gcm.cache/std.gcm"), - std::string::npos); + expect(ne(body.find( + "build obj/lib-greet.m.o: dyndep | gcm.cache/std.gcm"), std::string::npos)); + expect(ne(body.find( + "build obj/lib.m.o: dyndep | gcm.cache/myapp.lib-greet.gcm gcm.cache/std.gcm"), std::string::npos)); + expect(ne(body.find( + "build obj/main.o: dyndep | gcm.cache/myapp.lib.gcm gcm.cache/std.gcm"), std::string::npos)); // Each entry must end with restat = 1. int restat_count = 0; for (std::size_t pos = 0; (pos = body.find("restat = 1", pos)) != std::string::npos; ++pos) ++restat_count; - EXPECT_EQ(restat_count, 3); -} + expect(eq(restat_count, 3)); +}}; -TEST(Dyndep, EmitDyndepNoRequires) { +UTest emit_dyndep_no_requires{"Dyndep/EmitDyndepNoRequires", [] { std::vector units = { { "obj/leaf.m.o", {"leaf"}, {} }, }; auto body = emit_dyndep(units, {}); // No '|' implicit-deps section when requires is empty. - EXPECT_NE(body.find("build obj/leaf.m.o: dyndep\n"), std::string::npos); -} + expect(ne(body.find("build obj/leaf.m.o: dyndep\n"), std::string::npos)); +}}; -TEST(Dyndep, EmitDyndepSelfProvideFiltered) { +UTest emit_dyndep_self_provide_filtered{"Dyndep/EmitDyndepSelfProvideFiltered", [] { // Defensive: even if a unit lists its own provides in requires // (shouldn't happen but be robust), we don't emit a self-loop. std::vector units = { { "obj/foo.m.o", {"foo"}, {"foo", "std"} }, }; auto body = emit_dyndep(units, {}); - EXPECT_NE(body.find( - "build obj/foo.m.o: dyndep | gcm.cache/std.gcm\n"), std::string::npos); - EXPECT_EQ(body.find("gcm.cache/foo.gcm"), std::string::npos); -} + expect(ne(body.find( + "build obj/foo.m.o: dyndep | gcm.cache/std.gcm\n"), std::string::npos)); + expect(eq(body.find("gcm.cache/foo.gcm"), std::string::npos)); +}}; // Two-phase (clang): one source, two edges — a BMI edge and an object edge — // and BOTH parse the same imports, so both need the same implicit inputs. @@ -86,7 +86,7 @@ TEST(Dyndep, EmitDyndepSelfProvideFiltered) { // command's `-o`), and an edge with no record is not a warning: ninja refuses // the whole graph with "'…pcm' not mentioned in its dyndep file", naming the // edge rather than the missing record. -TEST(Dyndep, SplitModuleEmitsARecordForBothEdges) { +UTest split_module_emits_a_record_for_both_edges{"Dyndep/SplitModuleEmitsARecordForBothEdges", [] { std::vector units = { { "obj/lib.m.o", {"myapp.lib"}, {"std"} }, }; @@ -96,16 +96,14 @@ TEST(Dyndep, SplitModuleEmitsARecordForBothEdges) { opts.splitModuleEdges = true; auto body = emit_dyndep(units, {}, opts); - EXPECT_NE(body.find("build pcm.cache/myapp.lib.pcm: dyndep | pcm.cache/std.pcm\n"), - std::string::npos) << body; - EXPECT_NE(body.find("build obj/lib.m.o: dyndep | pcm.cache/std.pcm\n"), - std::string::npos) << body; -} + expect(ne(body.find("build pcm.cache/myapp.lib.pcm: dyndep | pcm.cache/std.pcm\n"), std::string::npos)) << body; + expect(ne(body.find("build obj/lib.m.o: dyndep | pcm.cache/std.pcm\n"), std::string::npos)) << body; +}}; // A unit that provides nothing is NOT split — there is only one edge, so a // second record would name something nobody declared. Same failure text as the // missing-record case, from the opposite mistake. -TEST(Dyndep, SplitModuleLeavesNonProvidingUnitsAlone) { +UTest split_module_leaves_non_providing_units_alone{"Dyndep/SplitModuleLeavesNonProvidingUnitsAlone", [] { std::vector units = { { "obj/main.o", {}, {"myapp.lib"} }, }; @@ -119,15 +117,14 @@ TEST(Dyndep, SplitModuleLeavesNonProvidingUnitsAlone) { for (auto pos = body.find("build "); pos != std::string::npos; pos = body.find("build ", pos + 1)) ++records; - EXPECT_EQ(records, 1u) << body; - EXPECT_NE(body.find("build obj/main.o: dyndep | gcm.cache/myapp.lib.gcm\n"), - std::string::npos) << body; -} + expect(eq(records, 1u)) << body; + expect(ne(body.find("build obj/main.o: dyndep | gcm.cache/myapp.lib.gcm\n"), std::string::npos)) << body; +}}; // The single-unit path is what ninja actually runs (one .ddi → one .dd), and it // used to be a separate hand-written copy of the loop above. Pinned together so // a change to one shape cannot silently leave the other behind. -TEST(Dyndep, SplitModuleSingleFileMatchesTheBatchShape) { +UTest split_module_single_file_matches_the_batch_shape{"Dyndep/SplitModuleSingleFileMatchesTheBatchShape", [] { auto tmp = std::filesystem::temp_directory_path() / std::format("mcpp_dyndep_split_{}", std::random_device{}()); std::filesystem::create_directories(tmp); @@ -141,15 +138,15 @@ TEST(Dyndep, SplitModuleSingleFileMatchesTheBatchShape) { opts.splitModuleEdges = true; auto single = emit_dyndep_single(p, opts); - ASSERT_TRUE(single) << single.error(); + if (auto&& failed = expect(fatal(bool(single))); !failed) failed << single.error(); std::vector units = { { "obj/lib.m.o", {"myapp.lib"}, {"std"} } }; - EXPECT_EQ(*single, emit_dyndep(units, {}, opts)); + expect(eq(*single, emit_dyndep(units, {}, opts))); std::filesystem::remove_all(tmp); -} +}}; -TEST(Dyndep, EmitDyndepFromFiles) { +UTest dyndep_emit_dyndep_from_files{"Dyndep/EmitDyndepFromFiles", [] { auto tmp = std::filesystem::temp_directory_path() / std::format("mcpp_dyndep_test_{}", std::random_device{}()); std::filesystem::create_directories(tmp); @@ -166,54 +163,54 @@ TEST(Dyndep, EmitDyndepFromFiles) { })"; auto body = emit_dyndep_from_files({a, b}, {}); - ASSERT_TRUE(body) << body.error(); - EXPECT_NE(body->find("build obj/a.m.o: dyndep\n"), std::string::npos); - EXPECT_NE(body->find( - "build obj/b.m.o: dyndep | gcm.cache/a.gcm\n"), std::string::npos); + if (auto&& failed = expect(fatal(bool(body))); !failed) failed << body.error(); + expect(ne(body->find("build obj/a.m.o: dyndep\n"), std::string::npos)); + expect(ne(body->find( + "build obj/b.m.o: dyndep | gcm.cache/a.gcm\n"), std::string::npos)); std::filesystem::remove_all(tmp); -} +}}; // ── plan-vs-ddi reconciliation (scan_overrides auditor) ── -TEST(VerifyUnitExpectations, MatchPasses) { +UTest match_passes{"VerifyUnitExpectations/MatchPasses", [] { mcpp::dyndep::UnitInfo u; u.primaryOutput = "obj/fmt.o"; u.provides = {"fmt"}; u.requires_ = {"std"}; auto err = mcpp::dyndep::verify_unit_expectations(u, "fmt", {"std"}); - EXPECT_FALSE(err.has_value()) << *err; -} + if (auto&& failed = expect(!err.has_value()); !failed) failed << *err; +}}; -TEST(VerifyUnitExpectations, DivergenceReportsBothSides) { +UTest divergence_reports_both_sides{"VerifyUnitExpectations/DivergenceReportsBothSides", [] { mcpp::dyndep::UnitInfo u; u.primaryOutput = "obj/fmt.o"; u.provides = {"fmt"}; u.requires_ = {"std"}; auto err = mcpp::dyndep::verify_unit_expectations(u, "fmt", {}); - ASSERT_TRUE(err.has_value()); - EXPECT_NE(err->find("divergence"), std::string::npos); - EXPECT_NE(err->find("planned"), std::string::npos); - EXPECT_NE(err->find("std"), std::string::npos); -} + expect(fatal(err.has_value())); + expect(ne(err->find("divergence"), std::string::npos)); + expect(ne(err->find("planned"), std::string::npos)); + expect(ne(err->find("std"), std::string::npos)); +}}; -TEST(VerifyUnitExpectations, ExpectNoneMatchesEmptyUnit) { +UTest expect_none_matches_empty_unit{"VerifyUnitExpectations/ExpectNoneMatchesEmptyUnit", [] { mcpp::dyndep::UnitInfo u; u.primaryOutput = "obj/plain.o"; auto err = mcpp::dyndep::verify_unit_expectations(u, std::nullopt, {}); - EXPECT_FALSE(err.has_value()); -} + expect(!err.has_value()); +}}; // GCC's map names its repository with `$root`, and every relative path after // it lies below that directory: the dyndep step must name the BMI the compiler // reads, or ninja orders the compile before the BMI exists. -TEST(Dyndep, ParseModuleMapRootedAtTheBmiDirectory) { +UTest parse_module_map_rooted_at_the_bmi_directory{"Dyndep/ParseModuleMapRootedAtTheBmiDirectory", [] { auto m = parse_module_map("$root gcm.cache\na a.gcm\ndep.m dep/dep.m.gcm\nstd std.gcm\n"); - EXPECT_EQ(m.size(), 3u); - EXPECT_FALSE(m.contains("$root")); - EXPECT_EQ(m.at("a"), "gcm.cache/a.gcm"); - EXPECT_EQ(m.at("dep.m"), "gcm.cache/dep/dep.m.gcm"); + expect(eq(m.size(), 3u)); + expect(!m.contains("$root")); + expect(eq(m.at("a"), "gcm.cache/a.gcm")); + expect(eq(m.at("dep.m"), "gcm.cache/dep/dep.m.gcm")); // Without `$root` (clang's and MSVC's maps) a path is taken as written. auto plain = parse_module_map("m pcm.cache/core/m.pcm\n"); - EXPECT_EQ(plain.at("m"), "pcm.cache/core/m.pcm"); -} + expect(eq(plain.at("m"), "pcm.cache/core/m.pcm")); +}}; diff --git a/tests/unit/test_elf_runtime.cpp b/tests/unit/test_elf_runtime.cpp index fee1db493..94712b287 100644 --- a/tests/unit/test_elf_runtime.cpp +++ b/tests/unit/test_elf_runtime.cpp @@ -1,12 +1,14 @@ -#include import std; +import mcpplibs.mtest; import mcpplibs.os.platform; import mcpp.runtime.elf; import mcpp.runtime.binding; import mcpp.toolchain.post_install; import mcpp.build.symbol_provision; +using namespace mcpplibs::mtest; + namespace elf = mcpp::runtime::elf; namespace runtime = mcpp::runtime; namespace tc = mcpp::toolchain; @@ -198,7 +200,7 @@ elf::ElfRuntimeFacts facts(std::filesystem::path path, return out; } -TEST(RuntimePayload, ExactBindingWinsRegardlessOfDirectoryOrder) { +UTest exact_binding_wins_regardless_of_directory_order{"RuntimePayload/ExactBindingWinsRegardlessOfDirectoryOrder", [] { Tmp t; auto root = t.path / "xim-x-glibc"; std::filesystem::create_directories(root / "2.39" / "lib64"); @@ -207,11 +209,11 @@ TEST(RuntimePayload, ExactBindingWinsRegardlessOfDirectoryOrder) { std::ofstream(root / "2.44" / "lib64" / "ld-linux-x86-64.so.2") << "x"; auto selected = tc::select_glibc_payload_lib(root, "glibc@2.44"); - ASSERT_TRUE(selected.has_value()) << selected.error(); - EXPECT_EQ(*selected, root / "2.44" / "lib64"); -} + if (auto&& failed = expect(fatal(selected.has_value())); !failed) failed << selected.error(); + expect(eq(*selected, root / "2.44" / "lib64")); +}}; -TEST(RuntimePayload, MissingOrMalformedBindingIsAnError) { +UTest missing_or_malformed_binding_is_an_error{"RuntimePayload/MissingOrMalformedBindingIsAnError", [] { Tmp t; auto root = t.path / "xim-x-glibc"; std::filesystem::create_directories(root / "2.39" / "lib64"); @@ -219,46 +221,42 @@ TEST(RuntimePayload, MissingOrMalformedBindingIsAnError) { for (auto id : {"", "glibc", "glibc@2.44", "musl@1.2.5", "glibc@../2.39"}) { auto selected = tc::select_glibc_payload_lib(root, id); - EXPECT_FALSE(selected.has_value()) << id; - if (!selected) EXPECT_FALSE(selected.error().empty()); + expect(!selected.has_value()) << id; + if (!selected) expect(!(selected.error().empty())); } -} +}}; -TEST(ElfRuntime, ParsesProgramDynamicAndGnuVersionTablesInternally) { +UTest parses_program_dynamic_and_gnu_version_tables_internally{"ElfRuntime/ParsesProgramDynamicAndGnuVersionTablesInternally", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF table inspection is exercised on native Linux"; + skip("ELF table inspection is exercised on native Linux"); Tmp t; auto parsed = elf::inspect_elf_runtime(write_elf_fixture(t.path / "fixture")); - ASSERT_TRUE(parsed.has_value()) << parsed.error(); - EXPECT_EQ(parsed->interp, - "/store/glibc/2.44/lib64/ld-linux-x86-64.so.2"); - EXPECT_EQ(parsed->runpaths, - (std::vector{"/host/z", "/host/a"})); - EXPECT_EQ(parsed->needed, (std::vector{"libc.so.6"})); - EXPECT_EQ(parsed->requiredGlibcVersions, - (std::vector{"GLIBC_2.40"})); - EXPECT_EQ(parsed->definedGlibcVersions, - (std::vector{"GLIBC_2.44"})); -} - -TEST(ElfRuntime, RejectsUnsupportedOrTruncatedElfWithoutGuessing) { + if (auto&& failed = expect(fatal(parsed.has_value())); !failed) failed << parsed.error(); + expect(eq(parsed->interp, "/store/glibc/2.44/lib64/ld-linux-x86-64.so.2")); + expect(eq(parsed->runpaths, (std::vector{"/host/z", "/host/a"}))); + expect(eq(parsed->needed, (std::vector{"libc.so.6"}))); + expect(eq(parsed->requiredGlibcVersions, (std::vector{"GLIBC_2.40"}))); + expect(eq(parsed->definedGlibcVersions, (std::vector{"GLIBC_2.44"}))); +}}; + +UTest rejects_unsupported_or_truncated_elf_without_guessing{"ElfRuntime/RejectsUnsupportedOrTruncatedElfWithoutGuessing", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF table inspection is exercised on native Linux"; + skip("ELF table inspection is exercised on native Linux"); Tmp t; std::ofstream(t.path / "text") << "not an ELF"; auto text = elf::inspect_elf_runtime(t.path / "text"); - EXPECT_FALSE(text.has_value()); + expect(!text.has_value()); std::ofstream tiny(t.path / "tiny", std::ios::binary); tiny.write("\177ELF\2\1", 6); tiny.close(); auto truncated = elf::inspect_elf_runtime(t.path / "tiny"); - EXPECT_FALSE(truncated.has_value()); -} + expect(!truncated.has_value()); +}}; -TEST(ElfRuntime, ReusesAnAlreadyLoadedSonameAcrossDependencyRunpaths) { +UTest reuses_an_already_loaded_soname_across_dependency_runpaths{"ElfRuntime/ReusesAnAlreadyLoadedSonameAcrossDependencyRunpaths", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto payload = t.path / "store"; auto glibc39 = payload / "2.39" / "lib64"; @@ -286,12 +284,11 @@ TEST(ElfRuntime, ReusesAnAlreadyLoadedSonameAcrossDependencyRunpaths) { }); auto resolution = elf::resolve_runtime_closure(app, binding_for(payload)); - ASSERT_TRUE(resolution.unresolved.empty()); - ASSERT_EQ(resolution.resolvedLibcs.size(), 1u) + expect(fatal(resolution.unresolved.empty())); + expect(fatal(eq(resolution.resolvedLibcs.size(), 1u))) << "a later dependency must reuse the process-global libc SONAME"; - EXPECT_EQ(resolution.resolvedLibcs.front(), - std::filesystem::weakly_canonical(glibc44 / "libc.so.6")); -} + expect(eq(resolution.resolvedLibcs.front(), std::filesystem::weakly_canonical(glibc44 / "libc.so.6"))); +}}; // DT_RPATH REACHES THE WHOLE CHAIN; DT_RUNPATH REACHES ONE OBJECT. // @@ -304,9 +301,9 @@ TEST(ElfRuntime, ReusesAnAlreadyLoadedSonameAcrossDependencyRunpaths) { // build naming eight libraries, and the artifact it had just linked resolved // seven of them -- failing only on the one that belongs to a driver the // machine does not have. -TEST(ElfRuntime, ADependencyInheritsTheExecutablesRpath) { +UTest a_dependency_inherits_the_executables_rpath{"ElfRuntime/ADependencyInheritsTheExecutablesRpath", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto payload = t.path / "store"; auto glibc = payload / "2.44" / "lib64"; @@ -328,18 +325,18 @@ TEST(ElfRuntime, ADependencyInheritsTheExecutablesRpath) { }); auto resolution = elf::resolve_runtime_closure(app, binding_for(payload)); - EXPECT_TRUE(resolution.unresolvedSonames.empty()) + expect(resolution.unresolvedSonames.empty()) << "unresolved: " << (resolution.unresolvedSonames.empty() ? std::string{} : resolution.unresolvedSonames.front()); -} +}}; // …and the suppression, which is the half that makes the rule a rule. An // object carrying DT_RUNPATH uses no RPATH at all -- its own or inherited -- // so a test with only the leg above would also pass on an implementation that // inherited unconditionally. -TEST(ElfRuntime, ADependencyWithItsOwnRunpathDoesNotInheritOne) { +UTest a_dependency_with_its_own_runpath_does_not_inherit_one{"ElfRuntime/ADependencyWithItsOwnRunpathDoesNotInheritOne", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto payload = t.path / "store"; auto glibc = payload / "2.44" / "lib64"; @@ -363,13 +360,13 @@ TEST(ElfRuntime, ADependencyWithItsOwnRunpathDoesNotInheritOne) { }); auto resolution = elf::resolve_runtime_closure(app, binding_for(payload)); - ASSERT_EQ(resolution.unresolvedSonames.size(), 1u); - EXPECT_EQ(resolution.unresolvedSonames.front(), "libdeep.so"); -} + expect(fatal(eq(resolution.unresolvedSonames.size(), 1u))); + expect(eq(resolution.unresolvedSonames.front(), "libdeep.so")); +}}; -TEST(RuntimePhysics, RuleBRejectsInterpreterAndLibcFromDifferentPayloads) { +UTest rule_b_rejects_interpreter_and_libc_from_different_payloads{"RuntimePhysics/RuleBRejectsInterpreterAndLibcFromDifferentPayloads", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto b = binding_for(t.path / "store"); elf::RuntimeResolution r; @@ -380,13 +377,13 @@ TEST(RuntimePhysics, RuleBRejectsInterpreterAndLibcFromDifferentPayloads) { r.objects.push_back(facts(r.artifact.resolvedLibc, {}, {"GLIBC_2.39"})); auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); - EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::ProvenMismatch); - EXPECT_NE(verdict.explain().find("rule B"), std::string::npos); -} + expect(eq(verdict.status, elf::RuntimeVerdict::Status::ProvenMismatch)); + expect(ne(verdict.explain().find("rule B"), std::string::npos)); +}}; -TEST(RuntimePhysics, RuleBAcceptsSameSelectedPayload) { +UTest rule_b_accepts_same_selected_payload{"RuntimePhysics/RuleBAcceptsSameSelectedPayload", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto b = binding_for(t.path / "store"); elf::RuntimeResolution r; @@ -397,12 +394,12 @@ TEST(RuntimePhysics, RuleBAcceptsSameSelectedPayload) { r.objects.push_back(facts(r.artifact.resolvedLibc, {}, {"GLIBC_2.39", "GLIBC_2.44"})); auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); - EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Pass); -} + expect(eq(verdict.status, elf::RuntimeVerdict::Status::Pass)); +}}; -TEST(RuntimePhysics, RuleBRejectsTwoLibcsAcrossTheResolvedClosure) { +UTest rule_b_rejects_two_libcs_across_the_resolved_closure{"RuntimePhysics/RuleBRejectsTwoLibcsAcrossTheResolvedClosure", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto b = binding_for(t.path / "store"); elf::RuntimeResolution r; @@ -416,14 +413,13 @@ TEST(RuntimePhysics, RuleBRejectsTwoLibcsAcrossTheResolvedClosure) { r.objects.push_back(facts(r.artifact.resolvedLibc, {}, {"GLIBC_2.44"})); auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); - EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::ProvenMismatch); - EXPECT_NE(verdict.explain().find("more than one libc payload"), - std::string::npos); -} + expect(eq(verdict.status, elf::RuntimeVerdict::Status::ProvenMismatch)); + expect(ne(verdict.explain().find("more than one libc payload"), std::string::npos)); +}}; -TEST(RuntimePhysics, RuleARejectsRequiredFloorAboveSelectedLibcExports) { +UTest rule_a_rejects_required_floor_above_selected_libc_exports{"RuntimePhysics/RuleARejectsRequiredFloorAboveSelectedLibcExports", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto b = binding_for(t.path / "store", "2.39"); elf::RuntimeResolution r; @@ -435,14 +431,14 @@ TEST(RuntimePhysics, RuleARejectsRequiredFloorAboveSelectedLibcExports) { r.objects.push_back(facts("/lib64/libtinfo.so.6", {"GLIBC_2.42"})); auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); - EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::ProvenMismatch); - EXPECT_NE(verdict.explain().find("rule A"), std::string::npos); - EXPECT_NE(verdict.explain().find("libtinfo.so.6"), std::string::npos); -} + expect(eq(verdict.status, elf::RuntimeVerdict::Status::ProvenMismatch)); + expect(ne(verdict.explain().find("rule A"), std::string::npos)); + expect(ne(verdict.explain().find("libtinfo.so.6"), std::string::npos)); +}}; -TEST(RuntimePhysics, RuleAAcceptsEqualOrLowerFloor) { +UTest rule_a_accepts_equal_or_lower_floor{"RuntimePhysics/RuleAAcceptsEqualOrLowerFloor", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto b = binding_for(t.path / "store"); elf::RuntimeResolution r; @@ -454,8 +450,8 @@ TEST(RuntimePhysics, RuleAAcceptsEqualOrLowerFloor) { r.objects.push_back(facts("/host/lib.so", {"GLIBC_2.39", "GLIBC_2.44"})); auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); - EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Pass); -} + expect(eq(verdict.status, elf::RuntimeVerdict::Status::Pass)); +}}; // UNDER A HERMETIC BINDING, "not found" IS A MEASUREMENT. // @@ -465,12 +461,12 @@ TEST(RuntimePhysics, RuleAAcceptsEqualOrLowerFloor) { // the loader will give, and the program cannot start. Filing that under // `inconclusive` reports a proven failure as an absence of one, and it is how a // GL program that exited 127 was shipped as `validation: pass`. -TEST(RuntimePhysics, UnresolvedNeededUnderHermeticBindingIsProven) { +UTest unresolved_needed_under_hermetic_binding_is_proven{"RuntimePhysics/UnresolvedNeededUnderHermeticBindingIsProven", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto b = binding_for(t.path / "store"); - ASSERT_TRUE(b.hermetic()) << "this test's premise is a private loader"; + expect(fatal(bool(b.hermetic()))) << "this test's premise is a private loader"; elf::RuntimeResolution r; r.artifactIsElf = true; r.artifact = facts(t.path / "app"); @@ -480,11 +476,11 @@ TEST(RuntimePhysics, UnresolvedNeededUnderHermeticBindingIsProven) { r.unresolvedSonames = {"libgpu-driver.so"}; // a NEEDED nothing provides auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); - EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Unresolvable); - EXPECT_TRUE(verdict.blocking()) + expect(eq(verdict.status, elf::RuntimeVerdict::Status::Unresolvable)); + expect(bool(verdict.blocking())) << "a proven-unstartable artifact must fail the build, not warn"; - EXPECT_NE(verdict.explain().find("libgpu-driver.so"), std::string::npos); -} + expect(ne(verdict.explain().find("libgpu-driver.so"), std::string::npos)); +}}; // `allow_host_libs` opts out of BOTH phases, or it means two different things. // @@ -494,9 +490,9 @@ TEST(RuntimePhysics, UnresolvedNeededUnderHermeticBindingIsProven) { // the library is installed somewhere the private loader does look. It reports; // it does not block. (Without this, e2e 206's deliberate host-DSO control // failed its own build.) -TEST(RuntimePhysics, AllowHostLibsDowngradesTheProofToAReport) { +UTest allow_host_libs_downgrades_the_proof_to_a_report{"RuntimePhysics/AllowHostLibsDowngradesTheProofToAReport", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto b = binding_for(t.path / "store"); elf::RuntimeResolution r; @@ -509,17 +505,17 @@ TEST(RuntimePhysics, AllowHostLibsDowngradesTheProofToAReport) { auto blocked = elf::validate_runtime_artifact(r.artifact.artifact, b, r, /*hostLibsAllowed=*/false); - EXPECT_EQ(blocked.status, elf::RuntimeVerdict::Status::Unresolvable); - EXPECT_TRUE(blocked.blocking()); + expect(eq(blocked.status, elf::RuntimeVerdict::Status::Unresolvable)); + expect(bool(blocked.blocking())); auto reported = elf::validate_runtime_artifact(r.artifact.artifact, b, r, /*hostLibsAllowed=*/true); - EXPECT_EQ(reported.status, elf::RuntimeVerdict::Status::Inconclusive); - EXPECT_FALSE(reported.blocking()); - EXPECT_NE(reported.explain().find("libtinfo.so.6"), std::string::npos); - EXPECT_NE(reported.explain().find("allow_host_libs"), std::string::npos) + expect(eq(reported.status, elf::RuntimeVerdict::Status::Inconclusive)); + expect(!reported.blocking()); + expect(ne(reported.explain().find("libtinfo.so.6"), std::string::npos)); + expect(ne(reported.explain().find("allow_host_libs"), std::string::npos)) << "a downgraded verdict must say what downgraded it"; -} +}}; // THE ARTIFACT'S FORMAT DECIDES, NOT THE BINDING'S. // @@ -527,12 +523,12 @@ TEST(RuntimePhysics, AllowHostLibsDowngradesTheProofToAReport) { // private loader, therefore hermetic — while producing a PE. The parse failure // ("not an ELF file") landed in `unresolved`, and the artifact was failed for a // missing shared library it could not possibly have. CI caught it; this pins it. -TEST(RuntimePhysics, CrossBuiltNonElfArtifactIsNotJudgedByElfRules) { +UTest cross_built_non_elf_artifact_is_not_judged_by_elf_rules{"RuntimePhysics/CrossBuiltNonElfArtifactIsNotJudgedByElfRules", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto b = binding_for(t.path / "store"); - ASSERT_TRUE(b.hermetic()); + expect(fatal(bool(b.hermetic()))); elf::RuntimeResolution r; r.artifactIsElf = true; r.artifact = facts(t.path / "crosswin.exe"); @@ -541,18 +537,18 @@ TEST(RuntimePhysics, CrossBuiltNonElfArtifactIsNotJudgedByElfRules) { // and crucially NO unresolvedSonames — nothing was ever looked for. auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); - EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Pass); - EXPECT_FALSE(verdict.blocking()); - EXPECT_NE(verdict.explain().find("not ELF"), std::string::npos); -} + expect(eq(verdict.status, elf::RuntimeVerdict::Status::Pass)); + expect(!verdict.blocking()); + expect(ne(verdict.explain().find("not ELF"), std::string::npos)); +}}; // The narrower half of the same conflation. Even on a genuine ELF, "I could not // read one of the objects" or "I stopped after 512" are statements about the // CHECK — a check that could not look has proven nothing, so it stays // inconclusive no matter how hermetic the binding is. -TEST(RuntimePhysics, UnreadableObjectIsInconclusiveEvenWhenHermetic) { +UTest unreadable_object_is_inconclusive_even_when_hermetic{"RuntimePhysics/UnreadableObjectIsInconclusiveEvenWhenHermetic", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto b = binding_for(t.path / "store"); elf::RuntimeResolution r; @@ -564,21 +560,21 @@ TEST(RuntimePhysics, UnreadableObjectIsInconclusiveEvenWhenHermetic) { // no unresolvedSonames: it WAS found, it just could not be parsed auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); - EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Inconclusive); - EXPECT_FALSE(verdict.blocking()); -} + expect(eq(verdict.status, elf::RuntimeVerdict::Status::Inconclusive)); + expect(!verdict.blocking()); +}}; // The other side of the same line. Without a private loader the artifact runs // under the HOST's, which also consults `ld.so.cache` — something mcpp // deliberately does not parse. There, unresolved really is unknown, and // claiming otherwise would fail builds that work. -TEST(RuntimePhysics, UnresolvedNeededWithoutAPrivateLoaderStaysInconclusive) { +UTest unresolved_needed_without_a_private_loader_stays_inconclusive{"RuntimePhysics/UnresolvedNeededWithoutAPrivateLoaderStaysInconclusive", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto b = binding_for(t.path / "store"); b.loader.reset(); // host runtime: gcc@system and friends - ASSERT_FALSE(b.hermetic()); + expect(fatal(!b.hermetic())); elf::RuntimeResolution r; r.artifactIsElf = true; r.artifact = facts(t.path / "app"); @@ -586,18 +582,18 @@ TEST(RuntimePhysics, UnresolvedNeededWithoutAPrivateLoaderStaysInconclusive) { r.unresolved = {"libgpu-driver.so"}; auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); - EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Inconclusive); - EXPECT_FALSE(verdict.blocking()); - EXPECT_NE(verdict.explain().find("libgpu-driver.so"), std::string::npos); -} + expect(eq(verdict.status, elf::RuntimeVerdict::Status::Inconclusive)); + expect(!verdict.blocking()); + expect(ne(verdict.explain().find("libgpu-driver.so"), std::string::npos)); +}}; // An undeclared SubOS on Linux is not "some other runtime" — it is a runtime // nobody described, and the two must not print the same sentence. Reporting the // second as the first sends the reader looking for a runtime they did not // select, and quietly counts "unknown" as "fine". -TEST(RuntimePhysics, UndeclaredBindingIsInconclusiveNotNotApplicable) { +UTest undeclared_binding_is_inconclusive_not_not_applicable{"RuntimePhysics/UndeclaredBindingIsInconclusiveNotNotApplicable", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto b = binding_for(t.path / "store"); b.declared = false; @@ -608,14 +604,13 @@ TEST(RuntimePhysics, UndeclaredBindingIsInconclusiveNotNotApplicable) { r.artifact = facts(t.path / "app"); auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); - EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Inconclusive); - EXPECT_NE(verdict.explain().find("does not describe its runtime"), - std::string::npos); -} + expect(eq(verdict.status, elf::RuntimeVerdict::Status::Inconclusive)); + expect(ne(verdict.explain().find("does not describe its runtime"), std::string::npos)); +}}; -TEST(RuntimePhysics, NonLinuxValidatorIsATypedNoop) { +UTest non_linux_validator_is_a_typed_noop{"RuntimePhysics/NonLinuxValidatorIsATypedNoop", [] { if constexpr (mcpplibs::os::is_linux) - GTEST_SKIP() << "the non-Linux boundary is exercised on native runners"; + skip("the non-Linux boundary is exercised on native runners"); Tmp t; auto b = binding_for(t.path / "store"); elf::RuntimeResolution r; @@ -625,9 +620,9 @@ TEST(RuntimePhysics, NonLinuxValidatorIsATypedNoop) { r.unresolved = {"libgpu-driver.so"}; auto verdict = elf::validate_runtime_artifact(r.artifact.artifact, b, r); - EXPECT_EQ(verdict.status, elf::RuntimeVerdict::Status::Pass); - EXPECT_NE(verdict.explain().find("non-Linux"), std::string::npos); -} + expect(eq(verdict.status, elf::RuntimeVerdict::Status::Pass)); + expect(ne(verdict.explain().find("non-Linux"), std::string::npos)); +}}; } // namespace @@ -638,46 +633,46 @@ TEST(RuntimePhysics, NonLinuxValidatorIsATypedNoop) { // the RULE is right about what a linker actually produces — and the rule was // wrong twice before it was measured. -TEST(ElfRuntime, ReadsThisBinarysOwnDynamicSymbols) { +UTest reads_this_binarys_own_dynamic_symbols{"ElfRuntime/ReadsThisBinarysOwnDynamicSymbols", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF dynamic symbol tables only exist on Linux here"; + skip("ELF dynamic symbol tables only exist on Linux here"); auto self = std::filesystem::path("/proc/self/exe"); - if (!std::filesystem::exists(self)) GTEST_SKIP() << "no /proc/self/exe"; + if (!std::filesystem::exists(self)) skip("no /proc/self/exe"); auto symbols = elf::inspect_dynamic_symbols(self); - ASSERT_TRUE(symbols.has_value()) << symbols.error(); + if (auto&& failed = expect(fatal(symbols.has_value())); !failed) failed << symbols.error(); if (!symbols->present) - GTEST_SKIP() << "this test binary is statically linked"; + skip("this test binary is statically linked"); // The DENOMINATOR has to be real. `exported = 0` out of an unread table // is the reading this whole area exists to make impossible. - EXPECT_GT(symbols->total, 0u); - EXPECT_TRUE(symbols->copyRelocationsKnown) + expect(gt(symbols->total, 0u)); + expect(bool(symbols->copyRelocationsKnown)) << "x86_64/aarch64/riscv64 must all be in the COPY relocation table"; -} +}}; -TEST(ElfRuntime, ThisBinaryExportsNothingOfItsOwn) { +UTest this_binary_exports_nothing_of_its_own{"ElfRuntime/ThisBinaryExportsNothingOfItsOwn", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF dynamic symbol tables only exist on Linux here"; + skip("ELF dynamic symbol tables only exist on Linux here"); auto self = std::filesystem::path("/proc/self/exe"); - if (!std::filesystem::exists(self)) GTEST_SKIP() << "no /proc/self/exe"; + if (!std::filesystem::exists(self)) skip("no /proc/self/exe"); auto facts = elf::inspect_elf_runtime(self); - ASSERT_TRUE(facts.has_value()) << facts.error(); + if (auto&& failed = expect(fatal(facts.has_value())); !failed) failed << facts.error(); // PT_INTERP, not the ELF type: a PIE executable is ET_DYN exactly like // a shared library, and whether this binary is PIE is the payload // compiler's default rather than mcpp's decision. if (facts->interp.empty()) - GTEST_SKIP() << "statically linked: no loader, so no flat namespace"; + skip("statically linked: no loader, so no flat namespace"); auto symbols = elf::inspect_dynamic_symbols(self); - ASSERT_TRUE(symbols.has_value()) << symbols.error(); - ASSERT_TRUE(symbols->present); + if (auto&& failed = expect(fatal(symbols.has_value())); !failed) failed << symbols.error(); + expect(fatal(bool(symbols->present))); auto exported = mcpp::build::symbol_provision::exported_definitions(*symbols); - ASSERT_TRUE(exported.has_value()); + expect(fatal(exported.has_value())); // Measured on every mcpp binary: the only defined entries are glibc's // copy relocations (environ, __environ, stdout, stderr, @@ -686,14 +681,13 @@ TEST(ElfRuntime, ThisBinaryExportsNothingOfItsOwn) { // name-keyed filter reports it in every executable ever built. std::vector names; for (auto const& e : *exported) names.push_back(e.name); - EXPECT_TRUE(names.empty()) - << "an ordinary mcpp-linked test binary must export nothing of its " + if (auto&& failed = expect(names.empty()); !failed) failed << "an ordinary mcpp-linked test binary must export nothing of its " "own; got: " << [&] { std::string s; for (auto const& n : names) { s += n; s += ' '; } return s; }(); -} +}}; // THE SURFACE A DEPENDENCY PUBLISHES FOR dlopen, WHICH NO ARTIFACT WALK REACHES. // @@ -708,9 +702,9 @@ TEST(ElfRuntime, ThisBinaryExportsNothingOfItsOwn) { // legitimately holds host-driver links that dangle on a machine with no // driver, and a check that failed there would turn a correct CPU-only // configuration into a failed build. -TEST(DlopenSurface, SeparatesAPackagingGapFromAMachineWithoutTheDriver) { +UTest separates_a_packaging_gap_from_a_machine_without_the_driver{"DlopenSurface/SeparatesAPackagingGapFromAMachineWithoutTheDriver", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto payload = t.path / "store"; auto glibc = payload / "2.44" / "lib64"; @@ -738,11 +732,11 @@ TEST(DlopenSurface, SeparatesAPackagingGapFromAMachineWithoutTheDriver) { // BOTH DENOMINATORS. A surface that failed to build enumerates nothing and // every per-member assertion below then passes. - EXPECT_EQ(report.members, 4u); - EXPECT_EQ(report.walked, 3u) << "the dangling entry is not read, and the " + expect(eq(report.members, 4u)); + expect(eq(report.walked, 3u)) << "the dangling entry is not read, and the " "other three are"; - ASSERT_EQ(report.findings.size(), 2u); + expect(fatal(eq(report.findings.size(), 2u))); // Asserted as FIELDS, not as a substring of a message whose wording is // free to improve. const elf::DlopenSurfaceFinding* gap = nullptr; @@ -751,22 +745,22 @@ TEST(DlopenSurface, SeparatesAPackagingGapFromAMachineWithoutTheDriver) { if (f.soname == "libabsent.so.7") gap = &f; if (f.soname == "libdriver.so.1") machine = &f; } - ASSERT_NE(gap, nullptr); - ASSERT_NE(machine, nullptr); - EXPECT_FALSE(gap->dangling) << "nothing on the surface answers this name"; - EXPECT_EQ(gap->member.filename(), "libgap.so.1"); - EXPECT_TRUE(machine->dangling) + expect(fatal(ne(gap, nullptr))); + expect(fatal(ne(machine, nullptr))); + expect(!gap->dangling) << "nothing on the surface answers this name"; + expect(eq(gap->member.filename(), "libgap.so.1")); + expect(bool(machine->dangling)) << "the surface carries the entry; the target is the machine's answer"; -} +}}; // ONE LIBRARY, NOT ITS TWO NAMES. // // A farm links `libfoo.so.N` and `libfoo.so.N.M.P` to one file. Walking the // entries reports every finding twice and calls thirteen libraries twenty-six, // which is what the first published record did. -TEST(DlopenSurface, CountsALibraryOnceWhenTwoNamesLinkToIt) { +UTest counts_a_library_once_when_two_names_link_to_it{"DlopenSurface/CountsALibraryOnceWhenTwoNamesLinkToIt", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "ELF/glibc runtime physics only apply on Linux"; + skip("ELF/glibc runtime physics only apply on Linux"); Tmp t; auto payload = t.path / "store"; auto glibc = payload / "2.44" / "lib64"; @@ -784,9 +778,9 @@ TEST(DlopenSurface, CountsALibraryOnceWhenTwoNamesLinkToIt) { std::vector search{farm, glibc}; auto report = elf::inspect_dlopen_surface(dirs, binding_for(payload), search); - EXPECT_EQ(report.members, 1u); - EXPECT_EQ(report.walked, 1u); - ASSERT_EQ(report.findings.size(), 1u); + expect(eq(report.members, 1u)); + expect(eq(report.walked, 1u)); + expect(fatal(eq(report.findings.size(), 1u))); // The SHORTEST name is kept: it is the one a dlopen asks for. - EXPECT_EQ(report.findings.front().member.filename(), "libtwo.so.1"); -} + expect(eq(report.findings.front().member.filename(), "libtwo.so.1")); +}}; diff --git a/tests/unit/test_fallback_legacy_dirs.cpp b/tests/unit/test_fallback_legacy_dirs.cpp index b38868241..eabed199a 100644 --- a/tests/unit/test_fallback_legacy_dirs.cpp +++ b/tests/unit/test_fallback_legacy_dirs.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.provider.xlings.legacy_dirs; +using namespace mcpplibs::mtest; + // The legacy install-dir scan must not cross namespaces. // // `Fetcher::install_path` contract is "the verdir for THIS package". Its @@ -31,43 +33,43 @@ std::filesystem::path make_xpkgs(std::initializer_list dirs) { } // namespace -TEST(FallbackLegacyDirs, DoesNotReturnAnotherNamespacesDirectory) { +UTest does_not_return_another_namespaces_directory{"FallbackLegacyDirs/DoesNotReturnAnotherNamespacesDirectory", [] { auto base = make_xpkgs({"compat-x-imgui"}); // Asking for ocornut:imgui. Only compat's directory exists, and it is NOT // an answer to this question. auto hit = mcpp::provider::xlings::scan_legacy_install_dirs( base, "ocornut.imgui", "imgui", {"ocornut", "mcpplibs"}); - EXPECT_FALSE(hit.has_value()) + expect(!hit.has_value()) << "returned '" << hit.value_or("") << "' for ocornut:imgui"; std::filesystem::remove_all(base); -} +}}; -TEST(FallbackLegacyDirs, StillFindsOwnNamespaceShortNameLayout) { +UTest still_finds_own_namespace_short_name_layout{"FallbackLegacyDirs/StillFindsOwnNamespaceShortNameLayout", [] { auto base = make_xpkgs({"compat-x-imgui", "ocornut-x-imgui"}); auto hit = mcpp::provider::xlings::scan_legacy_install_dirs( base, "ocornut.imgui", "imgui", {"ocornut", "mcpplibs"}); - ASSERT_TRUE(hit.has_value()); - EXPECT_EQ(*hit, "ocornut-x-imgui"); + expect(fatal(hit.has_value())); + expect(eq(*hit, "ocornut-x-imgui")); std::filesystem::remove_all(base); -} +}}; -TEST(FallbackLegacyDirs, StillFindsIndexPrefixedLegacyLayout) { +UTest still_finds_index_prefixed_legacy_layout{"FallbackLegacyDirs/StillFindsIndexPrefixedLegacyLayout", [] { // The old layout this arm exists for: -x-. auto base = make_xpkgs({"mcpplibs-x-tinyhttps"}); auto hit = mcpp::provider::xlings::scan_legacy_install_dirs( base, "mcpplibs.tinyhttps", "tinyhttps", {"mcpplibs", "mcpplibs"}); - ASSERT_TRUE(hit.has_value()); - EXPECT_EQ(*hit, "mcpplibs-x-tinyhttps"); + expect(fatal(hit.has_value())); + expect(eq(*hit, "mcpplibs-x-tinyhttps")); std::filesystem::remove_all(base); -} +}}; -TEST(FallbackLegacyDirs, QualifiedSuffixNeedsNoPrefixAllowance) { +UTest qualified_suffix_needs_no_prefix_allowance{"FallbackLegacyDirs/QualifiedSuffixNeedsNoPrefixAllowance", [] { // `-x-.` carries the namespace in the suffix itself, so it is // accepted whatever the prefix is — that is the legacy FQN layout. auto base = make_xpkgs({"someindex-x-ocornut.imgui"}); auto hit = mcpp::provider::xlings::scan_legacy_install_dirs( base, "ocornut.imgui", "imgui", {"ocornut"}); - ASSERT_TRUE(hit.has_value()); - EXPECT_EQ(*hit, "someindex-x-ocornut.imgui"); + expect(fatal(hit.has_value())); + expect(eq(*hit, "someindex-x-ocornut.imgui")); std::filesystem::remove_all(base); -} +}}; diff --git a/tests/unit/test_fetcher_install_path.cpp b/tests/unit/test_fetcher_install_path.cpp index 435d2bc9a..5e9a7d65f 100644 --- a/tests/unit/test_fetcher_install_path.cpp +++ b/tests/unit/test_fetcher_install_path.cpp @@ -3,12 +3,14 @@ // provider home's, which holds what was installed before that index had a // store of its own. -#include import std; +import mcpplibs.mtest; import mcpp.config; import mcpp.pm.package_fetcher; +using namespace mcpplibs::mtest; + namespace { struct TempDir { @@ -34,27 +36,27 @@ mcpp::config::GlobalConfig home_at(const std::filesystem::path& home) { return cfg; } -TEST(FetcherInstallPath, APackageIsFoundInItsIndexStore) { +UTest a_package_is_found_in_its_index_store{"FetcherInstallPath/APackageIsFoundInItsIndexStore", [] { TempDir temp; auto cfg = home_at(temp.path); const auto verdir = temp.path / "xpkgs" / "compat-x-gadget" / "1.0.0"; std::filesystem::create_directories(verdir); mcpp::pm::Fetcher f(cfg); const auto found = f.install_path("compat", "gadget", "1.0.0"); - ASSERT_TRUE(found); - EXPECT_EQ(found->lexically_normal(), verdir.lexically_normal()); -} + expect(fatal(bool(found))); + expect(eq(found->lexically_normal(), verdir.lexically_normal())); +}}; -TEST(FetcherInstallPath, APackageFromBeforeIsFoundInTheHomesStore) { +UTest a_package_from_before_is_found_in_the_homes_store{"FetcherInstallPath/APackageFromBeforeIsFoundInTheHomesStore", [] { TempDir temp; auto cfg = home_at(temp.path); const auto verdir = cfg.registryDir / "data" / "xpkgs" / "compat-x-gadget" / "1.0.0"; std::filesystem::create_directories(verdir); mcpp::pm::Fetcher f(cfg); const auto found = f.install_path("compat", "gadget", "1.0.0"); - ASSERT_TRUE(found); - EXPECT_EQ(found->lexically_normal(), verdir.lexically_normal()); - EXPECT_FALSE(f.install_path("compat", "gadget", "2.0.0")); -} + expect(fatal(bool(found))); + expect(eq(found->lexically_normal(), verdir.lexically_normal())); + expect(!f.install_path("compat", "gadget", "2.0.0")); +}}; } // namespace diff --git a/tests/unit/test_fingerprint.cpp b/tests/unit/test_fingerprint.cpp index 70e5e0eda..d64ae2aa8 100644 --- a/tests/unit/test_fingerprint.cpp +++ b/tests/unit/test_fingerprint.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.detect; import mcpp.toolchain.fingerprint; +using namespace mcpplibs::mtest; + using namespace mcpp::toolchain; namespace { @@ -34,7 +36,7 @@ FingerprintInputs baseline() { // interpreter cannot provide, and nothing along the way said anything. This // is the prerequisite for building the same project under two subos, not a // refinement of it. -TEST(Fingerprint, RuntimeBindingIsPartOfTheIdentity) { +UTest runtime_binding_is_part_of_the_identity{"Fingerprint/RuntimeBindingIsPartOfTheIdentity", [] { mcpp::toolchain::FingerprintInputs a; a.toolchain.compiler = mcpp::toolchain::CompilerId::GCC; a.toolchain.version = "16.1.0"; @@ -44,89 +46,86 @@ TEST(Fingerprint, RuntimeBindingIsPartOfTheIdentity) { auto b = a; b.toolchain.runtimeBinding = "glibc@2.44"; - EXPECT_NE(mcpp::toolchain::compute_fingerprint(a).hex, - mcpp::toolchain::compute_fingerprint(b).hex) + expect(ne(mcpp::toolchain::compute_fingerprint(a).hex, mcpp::toolchain::compute_fingerprint(b).hex)) << "same toolchain, same triple, different libc — sharing target// " "would replay objects compiled against the other one"; -} +}}; // ...and an unset binding is its own identity, not a collision with any // particular version. -TEST(Fingerprint, EmptyRuntimeBindingIsDistinct) { +UTest empty_runtime_binding_is_distinct{"Fingerprint/EmptyRuntimeBindingIsDistinct", [] { mcpp::toolchain::FingerprintInputs a; a.toolchain.targetTriple = "x86_64-linux-gnu"; a.toolchain.runtimeBinding = "glibc@2.39"; auto b = a; b.toolchain.runtimeBinding.clear(); - EXPECT_NE(mcpp::toolchain::compute_fingerprint(a).hex, - mcpp::toolchain::compute_fingerprint(b).hex); -} + expect(ne(mcpp::toolchain::compute_fingerprint(a).hex, mcpp::toolchain::compute_fingerprint(b).hex)); +}}; -TEST(Fingerprint, FullRuntimeContractHashOverridesTheLibcLabel) { +UTest full_runtime_contract_hash_overrides_the_libc_label{"Fingerprint/FullRuntimeContractHashOverridesTheLibcLabel", [] { mcpp::toolchain::FingerprintInputs a; a.toolchain.targetTriple = "x86_64-linux-gnu"; a.toolchain.runtimeBinding = "glibc@2.39"; a.toolchain.runtimeContractHash = "subos-el8-contract"; auto b = a; b.toolchain.runtimeContractHash = "subos-dev-contract"; - EXPECT_NE(mcpp::toolchain::compute_fingerprint(a).hex, - mcpp::toolchain::compute_fingerprint(b).hex); -} + expect(ne(mcpp::toolchain::compute_fingerprint(a).hex, mcpp::toolchain::compute_fingerprint(b).hex)); +}}; } // namespace -TEST(Fingerprint, DeterministicForSameInputs) { +UTest deterministic_for_same_inputs{"Fingerprint/DeterministicForSameInputs", [] { auto a = compute_fingerprint(baseline()); auto b = compute_fingerprint(baseline()); - EXPECT_EQ(a.hex, b.hex); -} + expect(eq(a.hex, b.hex)); +}}; -TEST(Fingerprint, ProducesSixteenHexChars) { +UTest produces_sixteen_hex_chars{"Fingerprint/ProducesSixteenHexChars", [] { auto fp = compute_fingerprint(baseline()); - EXPECT_EQ(fp.hex.size(), 16u); + expect(eq(fp.hex.size(), 16u)); for (char c : fp.hex) { - EXPECT_TRUE((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')); + expect((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')); } -} - -#define EXPECT_DIFFERENT(field_mutation) do { \ - auto base = baseline(); \ - auto bfp = compute_fingerprint(base); \ - auto in = baseline(); \ - field_mutation; \ - auto fp = compute_fingerprint(in); \ - EXPECT_NE(bfp.hex, fp.hex) \ - << "Mutation '" #field_mutation "' did NOT change fingerprint"; \ - } while (0) - -TEST(Fingerprint, AllTenFieldsAffectHash) { - EXPECT_DIFFERENT(in.toolchain.compiler = CompilerId::Clang); - EXPECT_DIFFERENT(in.toolchain.version = "16.0.0"); - EXPECT_DIFFERENT(in.toolchain.driverIdent = "g++ (xim-x-gcc 15.1.0) 15.1.0"); - EXPECT_DIFFERENT(in.toolchain.targetTriple = "aarch64-linux-gnu"); - EXPECT_DIFFERENT(in.toolchain.stdlibId = "libc++"); - EXPECT_DIFFERENT(in.cppStandard = "c++26"); - // c++20 gets its own target dir for the same reason c++26 does — cross-level - // BMI reuse is rejected by the compiler itself. - EXPECT_DIFFERENT(in.cppStandard = "c++20"); - EXPECT_DIFFERENT(in.compileFlags = "-O3"); - // mcpp version is hardcoded inside compute_fingerprint, can't mutate from here. - EXPECT_DIFFERENT(in.dependencyLockHash = ""); - EXPECT_DIFFERENT(in.stdBmiHash = "ffffffffffffffff"); -} +}}; + +// Each field, changed alone from the baseline, changes the hash. +UTest all_ten_fields_affect_hash{"Fingerprint/AllTenFieldsAffectHash", [] { + using Mutation = std::pair>; + const Mutation mutations[] = { + {"toolchain.compiler", [](auto& in) { in.toolchain.compiler = CompilerId::Clang; }}, + {"toolchain.version", [](auto& in) { in.toolchain.version = "16.0.0"; }}, + {"toolchain.driverIdent", [](auto& in) { in.toolchain.driverIdent = "g++ (xim-x-gcc 15.1.0) 15.1.0"; }}, + {"toolchain.targetTriple", [](auto& in) { in.toolchain.targetTriple = "aarch64-linux-gnu"; }}, + {"toolchain.stdlibId", [](auto& in) { in.toolchain.stdlibId = "libc++"; }}, + {"cppStandard c++26", [](auto& in) { in.cppStandard = "c++26"; }}, + // c++20 gets its own target dir for the same reason c++26 does — cross-level + // BMI reuse is rejected by the compiler itself. + {"cppStandard c++20", [](auto& in) { in.cppStandard = "c++20"; }}, + {"compileFlags", [](auto& in) { in.compileFlags = "-O3"; }}, + // mcpp version is hardcoded inside compute_fingerprint, can't mutate from here. + {"dependencyLockHash", [](auto& in) { in.dependencyLockHash = ""; }}, + {"stdBmiHash", [](auto& in) { in.stdBmiHash = "ffffffffffffffff"; }}, + }; + const auto base = compute_fingerprint(baseline()); + for (auto const& [field, mutate] : mutations) { + auto in = baseline(); + mutate(in); + expect(ne(base.hex, compute_fingerprint(in).hex)) << "changing " << field << " did not change the fingerprint"; + } +}}; -TEST(Fingerprint, StableAcrossBinaryPathsWhenDriverIdentMatches) { +UTest stable_across_binary_paths_when_driver_ident_matches{"Fingerprint/StableAcrossBinaryPathsWhenDriverIdentMatches", [] { auto a = baseline(); auto b = baseline(); a.toolchain.binaryPath = "/home/speak/.mcpp/registry/data/xpkgs/xim-x-gcc/16.1.0/bin/g++"; b.toolchain.binaryPath = "/home/speak/.xlings/data/xpkgs/xim-x-mcpp/0.0.14/registry/data/xpkgs/xim-x-gcc/16.1.0/bin/g++"; - EXPECT_EQ(compute_fingerprint(a).hex, compute_fingerprint(b).hex); -} + expect(eq(compute_fingerprint(a).hex, compute_fingerprint(b).hex)); +}}; -TEST(Fingerprint, HashStringMatchesHashFile) { +UTest hash_string_matches_hash_file{"Fingerprint/HashStringMatchesHashFile", [] { auto h1 = hash_string("hello"); auto h2 = hash_string("hello"); - EXPECT_EQ(h1, h2); - EXPECT_NE(hash_string("hello"), hash_string("hellp")); -} + expect(eq(h1, h2)); + expect(ne(hash_string("hello"), hash_string("hellp"))); +}}; diff --git a/tests/unit/test_freestanding.cpp b/tests/unit/test_freestanding.cpp index f87bfe792..54198337a 100644 --- a/tests/unit/test_freestanding.cpp +++ b/tests/unit/test_freestanding.cpp @@ -1,6 +1,7 @@ -#include import std; +import mcpplibs.mtest; +import mcpplibs.os.process; import mcpp.freestanding.target; import mcpp.freestanding.linkline; import mcpp.freestanding.runner; @@ -8,70 +9,72 @@ import mcpp.toolchain.triple; import mcpp.provider.xlings; import mcpp.build.build_program; +using namespace mcpplibs::mtest; + using namespace mcpp::freestanding; // ── the target table ──────────────────────────────────────────────────────── -TEST(FreestandingTarget, ResolvesTheKnownBareMetalTriples) { +UTest resolves_the_known_bare_metal_triples{"FreestandingTarget/ResolvesTheKnownBareMetalTriples", [] { auto rv64 = resolve("riscv64-none-elf"); - ASSERT_TRUE(rv64.has_value()); - EXPECT_EQ(rv64->march, "rv64gc"); - EXPECT_EQ(rv64->mabi, "lp64d"); - EXPECT_EQ(rv64->libdir, "rv64gc/lp64d"); + expect(fatal(rv64.has_value())); + expect(eq(rv64->march, "rv64gc")); + expect(eq(rv64->mabi, "lp64d")); + expect(eq(rv64->libdir, "rv64gc/lp64d")); auto rv32 = resolve("riscv32-none-elf"); - ASSERT_TRUE(rv32.has_value()); - EXPECT_EQ(rv32->march, "rv32imac"); - EXPECT_EQ(rv32->mabi, "ilp32"); - EXPECT_EQ(rv32->libdir, "rv32imac/ilp32"); -} + expect(fatal(rv32.has_value())); + expect(eq(rv32->march, "rv32imac")); + expect(eq(rv32->mabi, "ilp32")); + expect(eq(rv32->libdir, "rv32imac/ilp32")); +}}; -TEST(FreestandingTarget, HostedTriplesResolveToNothing) { +UTest hosted_triples_resolve_to_nothing{"FreestandingTarget/HostedTriplesResolveToNothing", [] { // The same call answers "is this a target I know how to build // freestanding", so a hosted triple must not produce a Spec — a caller // that got one would put -ffreestanding on an ordinary Linux build. for (const char* s : { "x86_64-linux-gnu", "x86_64-linux-musl", "aarch64-macos", "x86_64-windows-gnu" }) { - EXPECT_FALSE(resolve(s).has_value()) << s; + expect(!(resolve(s).has_value())) << s; } -} +}}; -TEST(FreestandingTarget, UnknownBareMetalTripleIsNotInvented) { +UTest unknown_bare_metal_triple_is_not_invented{"FreestandingTarget/UnknownBareMetalTripleIsNotInvented", [] { // Parses as freestanding, but mcpp has no ISA profile for it. Returning a // Spec here would mean guessing an -march, and a wrong -march produces an // object file that links and then executes garbage. auto t = mcpp::toolchain::triple::parse("arm-none-eabi"); - ASSERT_TRUE(t.has_value()); - EXPECT_TRUE(t->is_freestanding()); - EXPECT_FALSE(resolve(*t).has_value()); -} + expect(fatal(t.has_value())); + expect(t->is_freestanding()); + expect(!(resolve(*t).has_value())); +}}; -TEST(FreestandingTarget, CompileFlagsCarryTheIsaAndFreestanding) { +UTest compile_flags_carry_the_isa_and_freestanding{"FreestandingTarget/CompileFlagsCarryTheIsaAndFreestanding", [] { auto s = resolve("riscv64-none-elf"); - ASSERT_TRUE(s.has_value()); + expect(fatal(s.has_value())); auto fl = compile_flags(*s); auto has = [&](std::string_view f) { return std::ranges::find(fl, f) != fl.end(); }; - EXPECT_TRUE(has("-march=rv64gc")); - EXPECT_TRUE(has("-mabi=lp64d")); - EXPECT_TRUE(has("-mcmodel=medany")); + expect(bool(has("-march=rv64gc"))); + expect(bool(has("-mabi=lp64d"))); + expect(bool(has("-mcmodel=medany"))); // -ffreestanding is a COMPILE flag, not a link one: it changes what the // compiler may assume about the library, and that has to hold for every // TU including a dependency's. - EXPECT_TRUE(has("-ffreestanding")); + expect(bool(has("-ffreestanding"))); // The toolchain's libc++ headers are the HOST's: `#include ` // resolves to libc++'s wrapper, which opens a `__config_site` generated // for the host and absent here. A target-side C++ library reaches a // consumer as a MODULE, never as an include path. - EXPECT_TRUE(has("-nostdinc++")); -} + expect(bool(has("-nostdinc++"))); +}}; // ── the link line ─────────────────────────────────────────────────────────── -TEST(FreestandingLink, CarriesTargetSelectionAndRefusesTheHostedWorld) { +UTest carries_target_selection_and_refuses_the_hosted_world{"FreestandingLink/CarriesTargetSelectionAndRefusesTheHostedWorld", [] { auto s = resolve("riscv64-none-elf"); - ASSERT_TRUE(s.has_value()); + expect(fatal(s.has_value())); LinkInputs in; in.lld = "/payload/bin/ld.lld"; auto line = link_flags(*s, in, [](const std::filesystem::path& p) { @@ -80,15 +83,15 @@ TEST(FreestandingLink, CarriesTargetSelectionAndRefusesTheHostedWorld) { // --target is the load-bearing one: clang is ONE binary for every target, // so without it the link silently produces host objects. - EXPECT_NE(line.find("--target=riscv64-none-elf"), std::string::npos); - EXPECT_NE(line.find("-nostdlib"), std::string::npos); - EXPECT_NE(line.find("-nostartfiles"), std::string::npos); - EXPECT_NE(line.find("-static"), std::string::npos); -} + expect(ne(line.find("--target=riscv64-none-elf"), std::string::npos)); + expect(ne(line.find("-nostdlib"), std::string::npos)); + expect(ne(line.find("-nostartfiles"), std::string::npos)); + expect(ne(line.find("-static"), std::string::npos)); +}}; -TEST(FreestandingLink, AddressesTheLinkerByPathNotByName) { +UTest addresses_the_linker_by_path_not_by_name{"FreestandingLink/AddressesTheLinkerByPathNotByName", [] { auto s = resolve("riscv64-none-elf"); - ASSERT_TRUE(s.has_value()); + expect(fatal(s.has_value())); LinkInputs in; in.lld = "/payload/bin/ld.lld"; auto line = link_flags(*s, in, [](const std::filesystem::path& p) { @@ -97,60 +100,59 @@ TEST(FreestandingLink, AddressesTheLinkerByPathNotByName) { // `-fuse-ld=lld` resolves through PATH and finds GNU ld on any machine // with binutils earlier on it, which then dies with "unrecognised // emulation mode: elf64lriscv". Reproduced 2026-08-19. - EXPECT_NE(line.find("-fuse-ld=/payload/bin/ld.lld"), std::string::npos); - EXPECT_EQ(line.find("-fuse-ld=lld "), std::string::npos); -} + expect(ne(line.find("-fuse-ld=/payload/bin/ld.lld"), std::string::npos)); + expect(eq(line.find("-fuse-ld=lld "), std::string::npos)); +}}; -TEST(FreestandingLink, LinkerScriptOnlyWhenOneIsSupplied) { +UTest linker_script_only_when_one_is_supplied{"FreestandingLink/LinkerScriptOnlyWhenOneIsSupplied", [] { auto s = resolve("riscv64-none-elf"); - ASSERT_TRUE(s.has_value()); + expect(fatal(s.has_value())); auto esc = [](const std::filesystem::path& p) { return p.string(); }; LinkInputs none; - EXPECT_EQ(link_flags(*s, none, esc).find(" -T "), std::string::npos); + expect(eq(link_flags(*s, none, esc).find(" -T "), std::string::npos)); LinkInputs with; with.linkerScript = "/board/link.ld"; - EXPECT_NE(link_flags(*s, with, esc).find(" -T /board/link.ld"), - std::string::npos); -} + expect(ne(link_flags(*s, with, esc).find(" -T /board/link.ld"), std::string::npos)); +}}; -TEST(FreestandingLink, LldIsIdentifiedByItsOwnVersionOutput) { +UTest lld_is_identified_by_its_own_version_output{"FreestandingLink/LldIsIdentifiedByItsOwnVersionOutput", [] { // By output, not by filename — the filename is exactly what was wrong in // the failure this guards. - EXPECT_TRUE(version_output_is_lld("LLD 22.1.8 (compatible with GNU linkers)")); - EXPECT_FALSE(version_output_is_lld("GNU ld (GNU Binutils for Ubuntu) 2.42")); -} + expect(version_output_is_lld("LLD 22.1.8 (compatible with GNU linkers)")); + expect(!(version_output_is_lld("GNU ld (GNU Binutils for Ubuntu) 2.42"))); +}}; // ── the runner ────────────────────────────────────────────────────────────── -TEST(FreestandingRunner, AppendsTheArtifactWhenNoPlaceholder) { +UTest appends_the_artifact_when_no_placeholder{"FreestandingRunner/AppendsTheArtifactWhenNoPlaceholder", [] { std::vector tmpl{ "qemu-system-riscv64", "-machine", "virt", "-kernel" }; auto argv = expand(tmpl, "/build/firmware"); - ASSERT_EQ(argv.size(), 5u); - EXPECT_EQ(argv.front(), "qemu-system-riscv64"); - EXPECT_EQ(argv.back(), "/build/firmware"); -} + expect(fatal(eq(argv.size(), 5u))); + expect(eq(argv.front(), "qemu-system-riscv64")); + expect(eq(argv.back(), "/build/firmware")); +}}; -TEST(FreestandingRunner, SubstitutesThePlaceholderInPlace) { +UTest substitutes_the_placeholder_in_place{"FreestandingRunner/SubstitutesThePlaceholderInPlace", [] { // The emulator that wants the image in the middle rather than at the end. std::vector tmpl{ "runner", "--image={}", "--verbose" }; auto argv = expand(tmpl, "/build/firmware"); - ASSERT_EQ(argv.size(), 3u); - EXPECT_EQ(argv[1], "--image=/build/firmware"); - EXPECT_EQ(argv[2], "--verbose"); // nothing appended when substituted -} + expect(fatal(eq(argv.size(), 3u))); + expect(eq(argv[1], "--image=/build/firmware")); + expect(eq(argv[2], "--verbose")); // nothing appended when substituted +}}; -TEST(FreestandingRunner, TheMissingRunnerMessageNamesTheKeyAndTheTriple) { +UTest the_missing_runner_message_names_the_key_and_the_triple{"FreestandingRunner/TheMissingRunnerMessageNamesTheKeyAndTheTriple", [] { // The user has just been told the build succeeded and the run did not; the // gap between those two is the whole content of the message, so it has to // carry something pasteable. auto msg = no_runner_message("riscv64-none-elf"); - EXPECT_NE(msg.find("[target.riscv64-none-elf]"), std::string::npos); - EXPECT_NE(msg.find("runner = ["), std::string::npos); - EXPECT_NE(msg.find("board-support package"), std::string::npos); -} + expect(ne(msg.find("[target.riscv64-none-elf]"), std::string::npos)); + expect(ne(msg.find("runner = ["), std::string::npos)); + expect(ne(msg.find("board-support package"), std::string::npos)); +}}; // ── `[xlings] deps` → payload directory ───────────────────────────────────── // @@ -159,33 +161,33 @@ TEST(FreestandingRunner, TheMissingRunnerMessageNamesTheKeyAndTheTriple) { // reconstructed `/data/xpkgs/-x-/` would be coupled // to store internals mcpp is free to change. -TEST(XpkgRef, ParsesEverySpellingAManifestMayWrite) { +UTest parses_every_spelling_a_manifest_may_write{"XpkgRef/ParsesEverySpellingAManifestMayWrite", [] { using mcpp::provider::xlings::paths::parse_xpkg_ref; auto full = parse_xpkg_ref("xim:picolibc-riscv@1.8.12"); - EXPECT_EQ(full.ns, "xim"); - EXPECT_EQ(full.name, "picolibc-riscv"); - EXPECT_EQ(full.version, "1.8.12"); + expect(eq(full.ns, "xim")); + expect(eq(full.name, "picolibc-riscv")); + expect(eq(full.version, "1.8.12")); // No namespace: the manifest's default, spelled once in the parser rather // than at each call site. auto bare = parse_xpkg_ref("ninja"); - EXPECT_EQ(bare.ns, "xim"); - EXPECT_EQ(bare.name, "ninja"); - EXPECT_TRUE(bare.version.empty()); + expect(eq(bare.ns, "xim")); + expect(eq(bare.name, "ninja")); + expect(bare.version.empty()); // A non-xim namespace is ordinary, not exotic: `local:` is what a package // under development resolves to. auto local = parse_xpkg_ref("local:qemu-riscv@9.2.4-1"); - EXPECT_EQ(local.ns, "local"); - EXPECT_EQ(local.name, "qemu-riscv"); - EXPECT_EQ(local.version, "9.2.4-1"); -} + expect(eq(local.ns, "local")); + expect(eq(local.name, "qemu-riscv")); + expect(eq(local.version, "9.2.4-1")); +}}; -TEST(XpkgPayload, APinnedRefResolvesToThatVersionOrToNothing) { +UTest a_pinned_ref_resolves_to_that_version_or_to_nothing{"XpkgPayload/APinnedRefResolvesToThatVersionOrToNothing", [] { namespace xp = mcpp::provider::xlings::paths; auto base = std::filesystem::temp_directory_path() - / std::format("mcpp-xpkg-test-{}", ::getpid()); + / std::format("mcpp-xpkg-test-{}", mcpplibs::os::process::self()->id); std::filesystem::remove_all(base); std::filesystem::create_directories(base / "xim-x-demo" / "1.0.0"); std::filesystem::create_directories(base / "xim-x-demo" / "2.0.0"); @@ -193,30 +195,30 @@ TEST(XpkgPayload, APinnedRefResolvesToThatVersionOrToNothing) { // The whole point: asking for 1.0.0 and silently getting 2.0.0 is an // answer only discovered later, in the artifact. auto pinned = xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@1.0.0")); - ASSERT_TRUE(pinned.has_value()); - EXPECT_EQ(pinned->filename(), "1.0.0"); + expect(fatal(pinned.has_value())); + expect(eq(pinned->filename(), "1.0.0")); - EXPECT_FALSE(xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@9.9.9")) - .has_value()); + expect(!(xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@9.9.9")) + .has_value())); std::filesystem::remove_all(base); -} +}}; -TEST(XpkgPayload, AnUnpinnedRefTakesTheHighestVersionNumerically) { +UTest an_unpinned_ref_takes_the_highest_version_numerically{"XpkgPayload/AnUnpinnedRefTakesTheHighestVersionNumerically", [] { namespace xp = mcpp::provider::xlings::paths; auto base = std::filesystem::temp_directory_path() - / std::format("mcpp-xpkg-test2-{}", ::getpid()); + / std::format("mcpp-xpkg-test2-{}", mcpplibs::os::process::self()->id); std::filesystem::remove_all(base); for (auto v : { "0.4.2", "0.4.9", "0.4.11" }) std::filesystem::create_directories(base / "xim-x-demo" / v); // A plain string sort answers 0.4.9; picking the wrong payload is silent. auto latest = xp::xpkg_payload_at(base, xp::parse_xpkg_ref("demo")); - ASSERT_TRUE(latest.has_value()); - EXPECT_EQ(latest->filename(), "0.4.11"); + expect(fatal(latest.has_value())); + expect(eq(latest->filename(), "0.4.11")); std::filesystem::remove_all(base); -} +}}; // A CONSTRAINT IN THE VERSION POSITION IS A CONSTRAINT, NOT A DIRECTORY NAME. // @@ -225,73 +227,73 @@ TEST(XpkgPayload, AnUnpinnedRefTakesTheHighestVersionNumerically) { // directory name -- so a range installed a payload and then reported that // nothing was installed. That is what made a rule package unable to state a // floor: it declared `>=8.5.0`, got it, and `xpkg_dir` answered "". -TEST(XpkgPayload, AConstrainedRefTakesTheHighestInstalledVersionSatisfyingIt) { +UTest a_constrained_ref_takes_the_highest_installed_version_satisfying_it{"XpkgPayload/AConstrainedRefTakesTheHighestInstalledVersionSatisfyingIt", [] { namespace xp = mcpp::provider::xlings::paths; auto base = std::filesystem::temp_directory_path() - / std::format("mcpp-xpkg-test3-{}", ::getpid()); + / std::format("mcpp-xpkg-test3-{}", mcpplibs::os::process::self()->id); std::filesystem::remove_all(base); for (auto v : { "8.0.0", "8.5.0", "8.7.1" }) std::filesystem::create_directories(base / "xim-x-demo" / v); auto floor = xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@>=8.5.0")); - ASSERT_TRUE(floor.has_value()); - EXPECT_EQ(floor->filename(), "8.7.1"); + expect(fatal(floor.has_value())); + expect(eq(floor->filename(), "8.7.1")); // Bounded on both sides, so the highest is NOT the answer -- a test that // only used a lower bound would pass on an implementation that ignored the // requirement and took the newest. Spelled the way xlings reads a // conjunction (space-separated), because xlings is what resolved it. auto ranged = xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@>=8.0.0 <8.7.0")); - ASSERT_TRUE(ranged.has_value()); - EXPECT_EQ(ranged->filename(), "8.5.0"); + expect(fatal(ranged.has_value())); + expect(eq(ranged->filename(), "8.5.0")); // Unsatisfiable is absent, not "the closest one". - EXPECT_FALSE(xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@>=9.0.0")) - .has_value()); + expect(!(xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@>=9.0.0")) + .has_value())); // AND AN EXACT PIN STILL MEANS EXACTLY THAT. The constrained path must not // capture a pinned-and-absent request: 8.6.0 is missing and 8.7.1 is right // there, and answering with it is the failure the pin exists to prevent. - EXPECT_FALSE(xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@8.6.0")) - .has_value()); + expect(!(xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@8.6.0")) + .has_value())); std::filesystem::remove_all(base); -} +}}; // An installed version whose directory name is not a SemVer is addressable by // its exact spelling. `8.0.RC1` is a real CANN version. -TEST(XpkgPayload, AnUnparseableVersionIsStillAddressableExactly) { +UTest an_unparseable_version_is_still_addressable_exactly{"XpkgPayload/AnUnparseableVersionIsStillAddressableExactly", [] { namespace xp = mcpp::provider::xlings::paths; auto base = std::filesystem::temp_directory_path() - / std::format("mcpp-xpkg-test4-{}", ::getpid()); + / std::format("mcpp-xpkg-test4-{}", mcpplibs::os::process::self()->id); std::filesystem::remove_all(base); std::filesystem::create_directories(base / "xim-x-demo" / "8.0.RC1"); auto exact = xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@8.0.RC1")); - ASSERT_TRUE(exact.has_value()); - EXPECT_EQ(exact->filename(), "8.0.RC1"); + expect(fatal(exact.has_value())); + expect(eq(exact->filename(), "8.0.RC1")); // xlings' grammar reads it as 8, 0, RC, 1, so a range can test it, and the // lookup answers what xlings selected. A key with no numeric segment at // all is a name, and a name never answers a range. auto ranged = xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:demo@>=1.0")); - ASSERT_TRUE(ranged.has_value()); - EXPECT_EQ(ranged->filename(), "8.0.RC1"); + expect(fatal(ranged.has_value())); + expect(eq(ranged->filename(), "8.0.RC1")); std::filesystem::create_directories(base / "xim-x-named" / "nightly"); - EXPECT_FALSE(xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:named@>=1.0")) - .has_value()); + expect(!(xp::xpkg_payload_at(base, xp::parse_xpkg_ref("xim:named@>=1.0")) + .has_value())); std::filesystem::remove_all(base); -} +}}; // A BARE VERSION MEANS WHAT XLINGS MEANT BY IT (#712). The address // `libglvnd@1.7` installed `1.7.0.1`: one or two segments are a prefix range, // three or more are written-prefix equality, and a fourth segment is an // ordinary version. The lookup answered "" for every one of these before. -TEST(XpkgPayload, ABareVersionSelectsWhatXlingsSelected) { +UTest a_bare_version_selects_what_xlings_selected{"XpkgPayload/ABareVersionSelectsWhatXlingsSelected", [] { namespace xp = mcpp::provider::xlings::paths; auto base = std::filesystem::temp_directory_path() - / std::format("mcpp-xpkg-test5-{}", ::getpid()); + / std::format("mcpp-xpkg-test5-{}", mcpplibs::os::process::self()->id); std::filesystem::remove_all(base); for (auto v : { "1.7.0.1", "1.2.0", "1.2.5", "1.3.0", "12.9.1.4", "12.9.10" }) std::filesystem::create_directories(base / "xim-x-demo" / v); @@ -301,48 +303,46 @@ TEST(XpkgPayload, ABareVersionSelectsWhatXlingsSelected) { xp::parse_xpkg_ref(std::format("xim:demo@{}", ver))); return p ? p->filename().string() : std::string{}; }; - EXPECT_EQ(pick("1.7"), "1.7.0.1"); // prefix range [1.7, 1.8) - EXPECT_EQ(pick("1.7.0"), "1.7.0.1"); // written prefix 1.7.0 - EXPECT_EQ(pick("1.2"), "1.2.5"); // the highest 1.2.x, never 1.3 - EXPECT_EQ(pick("1.2.0"), "1.2.0"); // three segments: 1.2.0 exactly - EXPECT_EQ(pick("12.9.1"), "12.9.1.4"); // not 12.9.10 - EXPECT_EQ(pick("1"), "1.7.0.1"); // [1, 2) - EXPECT_EQ(pick("1.2.4"), ""); // absent is absent - EXPECT_EQ(pick("1.8.12"), ""); // never slides to a later minor + expect(eq(pick("1.7"), "1.7.0.1")); // prefix range [1.7, 1.8) + expect(eq(pick("1.7.0"), "1.7.0.1")); // written prefix 1.7.0 + expect(eq(pick("1.2"), "1.2.5")); // the highest 1.2.x, never 1.3 + expect(eq(pick("1.2.0"), "1.2.0")); // three segments: 1.2.0 exactly + expect(eq(pick("12.9.1"), "12.9.1.4")); // not 12.9.10 + expect(eq(pick("1"), "1.7.0.1")); // [1, 2) + expect(eq(pick("1.2.4"), "")); // absent is absent + expect(eq(pick("1.8.12"), "")); // never slides to a later minor std::filesystem::remove_all(base); -} +}}; // The revision xlings recorded for a payload (openxlings/xlings#620): the // field when present, 0 for a record that predates it, and nothing at all when // there is no record -- which is the case mcpp judges by its marker alone. -TEST(XpkgPayload, InstalledRevisionReadsTheXlingsRecord) { +UTest installed_revision_reads_the_xlings_record{"XpkgPayload/InstalledRevisionReadsTheXlingsRecord", [] { namespace xp = mcpp::provider::xlings::paths; auto dir = std::filesystem::temp_directory_path() - / std::format("mcpp-xpkg-rev-{}", ::getpid()); + / std::format("mcpp-xpkg-rev-{}", mcpplibs::os::process::self()->id); std::filesystem::remove_all(dir); std::filesystem::create_directories(dir); - EXPECT_FALSE(xp::installed_revision(dir).has_value()); + expect(!(xp::installed_revision(dir).has_value())); std::ofstream(dir / ".xpkg-install.json") << R"({"os":"linux","version":"2.44.3"})"; - EXPECT_EQ(xp::installed_revision(dir), 0); + expect(eq(xp::installed_revision(dir), 0)); std::ofstream(dir / ".xpkg-install.json") << R"({"version":"2.44.3","revision":1})"; - EXPECT_EQ(xp::installed_revision(dir), 1); + expect(eq(xp::installed_revision(dir), 1)); std::filesystem::remove_all(dir); -} +}}; -TEST(XpkgEnvVar, BothSpellingsAreDerivedFromOneSanitizer) { +UTest both_spellings_are_derived_from_one_sanitizer{"XpkgEnvVar/BothSpellingsAreDerivedFromOneSanitizer", [] { using mcpp::build::xpkg_env_var; // The two sides of the channel must agree; drifting apart would make the // interface answer "" for a package that is right there. - EXPECT_EQ(xpkg_env_var("xim", "picolibc-riscv"), - "MCPP_XPKG_XIM_PICOLIBC_RISCV_DIR"); - EXPECT_EQ(xpkg_env_var("", "picolibc-riscv"), - "MCPP_XPKG_PICOLIBC_RISCV_DIR"); -} + expect(eq(xpkg_env_var("xim", "picolibc-riscv"), "MCPP_XPKG_XIM_PICOLIBC_RISCV_DIR")); + expect(eq(xpkg_env_var("", "picolibc-riscv"), "MCPP_XPKG_PICOLIBC_RISCV_DIR")); +}}; // ── the target owns its C library ─────────────────────────────────────────── -TEST(FreestandingTarget, BareMetalRowsNameTheirSysroot) { +UTest bare_metal_rows_name_their_sysroot{"FreestandingTarget/BareMetalRowsNameTheirSysroot", [] { // The axis that stops every bare-metal PACKAGE from naming a libc. // Before it, a board-support package and a standard-library subset each // had to carry `[xlings] deps = ["xim:picolibc-riscv@..."]`, which bound @@ -351,115 +351,115 @@ TEST(FreestandingTarget, BareMetalRowsNameTheirSysroot) { for (const char* s : { "riscv64-none-elf", "riscv32-none-elf" }) { auto* k = mcpp::toolchain::triple::find_known_target( *mcpp::toolchain::triple::parse(s)); - ASSERT_NE(k, nullptr) << s; - EXPECT_FALSE(k->sysroot.empty()) << s; + expect(fatal(ne(k, nullptr))) << s; + expect(!(k->sysroot.empty())) << s; // Same spelling the install channel takes, so the two cannot drift. - EXPECT_TRUE(k->sysroot.starts_with("xim:")) << s; + expect(k->sysroot.starts_with("xim:")) << s; } -} +}}; -TEST(FreestandingTarget, HostedRowsNameNone) { +UTest hosted_rows_name_none{"FreestandingTarget/HostedRowsNameNone", [] { // A hosted target's libc comes with the compiler payload or through the // runtime binding. Naming one here would resolve a second copy. for (const char* s : { "x86_64-linux-gnu", "x86_64-linux-musl", "aarch64-macos", "x86_64-windows-gnu" }) { auto* k = mcpp::toolchain::triple::find_known_target( *mcpp::toolchain::triple::parse(s)); - ASSERT_NE(k, nullptr) << s; - EXPECT_TRUE(k->sysroot.empty()) << s; + expect(fatal(ne(k, nullptr))) << s; + expect(k->sysroot.empty()) << s; } -} +}}; // ── whole-graph properties ────────────────────────────────────────────────── -TEST(FreestandingFlags, ExceptionsAndRttiAreOffForTheWholeGraph) { +UTest exceptions_and_rtti_are_off_for_the_whole_graph{"FreestandingFlags/ExceptionsAndRttiAreOffForTheWholeGraph", [] { auto spec = resolve("riscv64-none-elf"); - ASSERT_TRUE(spec.has_value()); + expect(fatal(spec.has_value())); auto f = compile_flags(*spec); // These live with the TARGET, not in a project's cxxflags, because a // BMI records them: a dependency compiled with exceptions cannot be // imported by a TU without them, and clang reports that as a .pcm // "configuration mismatch" rather than as a flag disagreement. - EXPECT_TRUE(std::ranges::find(f, "-fno-exceptions") != f.end()); - EXPECT_TRUE(std::ranges::find(f, "-fno-rtti") != f.end()); + expect(std::ranges::find(f, "-fno-exceptions") != f.end()); + expect(std::ranges::find(f, "-fno-rtti") != f.end()); // The company they keep — same argument, already shipped. - EXPECT_TRUE(std::ranges::find(f, "-ffreestanding") != f.end()); - EXPECT_TRUE(std::ranges::find(f, "-nostdinc++") != f.end()); -} + expect(std::ranges::find(f, "-ffreestanding") != f.end()); + expect(std::ranges::find(f, "-nostdinc++") != f.end()); +}}; -TEST(FreestandingFlags, HostedTargetsGetNoneOfThis) { +UTest hosted_targets_get_none_of_this{"FreestandingFlags/HostedTargetsGetNoneOfThis", [] { // The flags are a property of a freestanding triple, and the cache key // reads them through the same resolve(). A hosted target must produce no // Spec, or every hosted key would move for no reason. - EXPECT_FALSE(resolve("x86_64-linux-gnu").has_value()); -} + expect(!(resolve("x86_64-linux-gnu").has_value())); +}}; // ── engine floor: a package that needs a newer mcpp ───────────────────────── -TEST(BuildProgramCompatHint, RecognisesAllThreeFrontendSpellings) { +UTest recognises_all_three_frontend_spellings{"BuildProgramCompatHint/RecognisesAllThreeFrontendSpellings", [] { using mcpp::build::mentions_missing_mcpp_api; // Measured, not assumed: `if constexpr (requires { mcpp::runner("x"); })` // is a HARD ERROR when the name is absent, so a package cannot probe for a // newer API in-language. The compiler's error IS the compat channel, and // these are the three ways it arrives. - EXPECT_TRUE(mentions_missing_mcpp_api( - "build.mcpp:57:11: error: 'runner' is not a member of 'mcpp'")); // gcc - EXPECT_TRUE(mentions_missing_mcpp_api( - "build.mcpp:57:11: error: no member named 'runner' in namespace 'mcpp'"));// clang - EXPECT_TRUE(mentions_missing_mcpp_api( - "build.mcpp(57): error C2039: 'runner': is not a member of 'mcpp'")); // cl.exe -} - -TEST(BuildProgramCompatHint, StaysOffFailuresThatAreNotAboutOurApi) { + expect(bool(mentions_missing_mcpp_api( + "build.mcpp:57:11: error: 'runner' is not a member of 'mcpp'"))); // gcc + expect(bool(mentions_missing_mcpp_api( + "build.mcpp:57:11: error: no member named 'runner' in namespace 'mcpp'")));// clang + expect(bool(mentions_missing_mcpp_api( + "build.mcpp(57): error C2039: 'runner': is not a member of 'mcpp'"))); // cl.exe +}}; + +UTest stays_off_failures_that_are_not_about_our_api{"BuildProgramCompatHint/StaysOffFailuresThatAreNotAboutOurApi", [] { using mcpp::build::mentions_missing_mcpp_api; // The hint says "your mcpp may be too old". Attaching that to an ordinary // compile error would send the reader to the wrong place, so the match is // anchored on OUR namespace — a package's own missing symbol names its own. - EXPECT_FALSE(mentions_missing_mcpp_api( + expect(!mentions_missing_mcpp_api( "build.mcpp:12:5: error: 'runner' is not a member of 'board'")); - EXPECT_FALSE(mentions_missing_mcpp_api( + expect(!mentions_missing_mcpp_api( "build.mcpp:3:1: error: expected ';' after top level declarator")); - EXPECT_FALSE(mentions_missing_mcpp_api( + expect(!mentions_missing_mcpp_api( "build.mcpp:9:9: error: use of undeclared identifier 'mcpp_runner'")); -} +}}; // ── artifact set ─────────────────────────────────────────────────────────── -TEST(FreestandingArtifacts, SizeOutputIsParsedNotPassedThrough) { +UTest size_output_is_parsed_not_passed_through{"FreestandingArtifacts/SizeOutputIsParsedNotPassedThrough", [] { // Parsed so the printed shape is mcpp's, not the tool's: llvm-size and GNU // size differ in their headers, and a build whose output changed // appearance with the toolchain would be reporting the tool, not the size. auto s = parse_size_output( " text\t data\t bss\t dec\t hex\tfilename\n" " 8836\t 80\t 5668\t 14584\t 38f8\tfirmware\n"); - ASSERT_TRUE(s.has_value()); - EXPECT_EQ(s->text, 8836); - EXPECT_EQ(s->data, 80); - EXPECT_EQ(s->bss, 5668); + expect(fatal(s.has_value())); + expect(eq(s->text, 8836)); + expect(eq(s->data, 80)); + expect(eq(s->bss, 5668)); // The number that decides whether the image fits. - EXPECT_EQ(size_total(*s), 14584); -} + expect(eq(size_total(*s), 14584)); +}}; -TEST(FreestandingArtifacts, MalformedSizeOutputIsNotGuessedAt) { +UTest malformed_size_output_is_not_guessed_at{"FreestandingArtifacts/MalformedSizeOutputIsNotGuessedAt", [] { // An informational line has no standing to invent numbers. - EXPECT_FALSE(parse_size_output("").has_value()); - EXPECT_FALSE(parse_size_output("size: cannot open 'x'\n").has_value()); -} + expect(!(parse_size_output("").has_value())); + expect(!(parse_size_output("size: cannot open 'x'\n").has_value())); +}}; -TEST(FreestandingArtifacts, MapFlagNamesTheArtifact) { +UTest map_flag_names_the_artifact{"FreestandingArtifacts/MapFlagNamesTheArtifact", [] { auto f = map_flag("/build/bin/firmware", [](const std::filesystem::path& p) { return p.string(); }); - EXPECT_EQ(f, " -Wl,-Map=/build/bin/firmware.map"); -} + expect(eq(f, " -Wl,-Map=/build/bin/firmware.map")); +}}; -TEST(FreestandingArtifacts, ObjcopyOnlyResolvesForABareMetalTarget) { +UTest objcopy_only_resolves_for_a_bare_metal_target{"FreestandingArtifacts/ObjcopyOnlyResolvesForABareMetalTarget", [] { // A hosted binary is loaded by a loader that wants the ELF, so there is // nothing to convert — and resolving a tool for it would put an edge in // every hosted graph. - EXPECT_TRUE(resolve_objcopy("/payload/bin/clang++", "x86_64-linux-gnu").empty()); + expect(resolve_objcopy("/payload/bin/clang++", "x86_64-linux-gnu").empty()); // (The bare-metal case needs a real payload on disk, so it is asserted by // tests/e2e/130 instead: the artifact set has to actually appear.) -} +}}; // ── aarch64-none-elf: the second bare-metal architecture ──────────────────── // @@ -476,32 +476,32 @@ TEST(FreestandingArtifacts, ObjcopyOnlyResolvesForABareMetalTarget) { // default assumes the low 2GiB and bare-metal RISC-V runs at 0x80000000; // aarch64's `small` already addresses +/-4GiB PC-relative, which covers any // single image wherever a linker script places it. -TEST(FreestandingTarget, Aarch64UsesTheProcedureCallStandardNotADataModel) { +UTest aarch64_uses_the_procedure_call_standard_not_a_data_model{"FreestandingTarget/Aarch64UsesTheProcedureCallStandardNotADataModel", [] { auto t = mcpp::toolchain::triple::parse("aarch64-none-elf"); - ASSERT_TRUE(t.has_value()); + expect(fatal(t.has_value())); auto s = mcpp::freestanding::resolve(t->str()); - ASSERT_TRUE(s.has_value()); - EXPECT_EQ(s->mabi, "aapcs"); - EXPECT_NE(s->mabi, "lp64"); - EXPECT_EQ(s->march, "armv8-a"); - EXPECT_EQ(s->mcmodel, "small"); -} + expect(fatal(s.has_value())); + expect(eq(s->mabi, "aapcs")); + expect(ne(s->mabi, "lp64")); + expect(eq(s->march, "armv8-a")); + expect(eq(s->mcmodel, "small")); +}}; // The row resolves no C library, which is what makes it the zero-libc tier: the // first consumer of this target is a layer of machine mechanism that references // no C library symbol, and there is no aarch64 picolibc in the index to resolve // even if it wanted one. -TEST(FreestandingTarget, Aarch64ResolvesNoCLibrary) { +UTest aarch64_resolves_no_c_library{"FreestandingTarget/Aarch64ResolvesNoCLibrary", [] { auto t = mcpp::toolchain::triple::parse("aarch64-none-elf"); - ASSERT_TRUE(t.has_value()); - EXPECT_TRUE(mcpp::toolchain::triple::effective_sysroot(*t, nullptr).empty()); + expect(fatal(t.has_value())); + expect(mcpp::toolchain::triple::effective_sysroot(*t, nullptr).empty()); // The other side: a project may still ASK for one, and the override is // what carries the request. An empty column means "nothing by default", not // "nothing is possible". const std::string want = "xim:some-aarch64-libc@1.0"; - EXPECT_EQ(mcpp::toolchain::triple::effective_sysroot(*t, &want), want); -} + expect(eq(mcpp::toolchain::triple::effective_sysroot(*t, &want), want)); +}}; // THIS TEST USED TO ASSERT THE OPPOSITE, AND IT WAS RIGHT UNTIL 2026-08-21. // @@ -521,27 +521,27 @@ TEST(FreestandingTarget, Aarch64ResolvesNoCLibrary) { // `.agents/tools/build-baremetal-sysroot.sh` produces them from the same // march/mabi pair this table carries, so the two cannot drift silently — but // they are named here so a reader knows where the other half lives. -TEST(FreestandingTarget, MultilibDirectoriesMatchTheIndexPayloads) { +UTest multilib_directories_match_the_index_payloads{"FreestandingTarget/MultilibDirectoriesMatchTheIndexPayloads", [] { auto a = mcpp::freestanding::resolve("aarch64-none-elf"); - ASSERT_TRUE(a.has_value()); - EXPECT_EQ(a->libdir, "armv8-a/aapcs"); + expect(fatal(a.has_value())); + expect(eq(a->libdir, "armv8-a/aapcs")); // The directory is the march/mabi pair, so it cannot disagree with the // flags the same row produces. - EXPECT_EQ(a->libdir, std::string(a->march) + "/" + std::string(a->mabi)); + expect(eq(a->libdir, std::string(a->march) + "/" + std::string(a->mabi))); auto x = mcpp::freestanding::resolve("x86_64-none-elf"); - ASSERT_TRUE(x.has_value()); - EXPECT_EQ(x->libdir, "x86-64/sysv"); - EXPECT_EQ(x->libdir, std::string(x->march) + "/" + std::string(x->mabi)); + expect(fatal(x.has_value())); + expect(eq(x->libdir, "x86-64/sysv")); + expect(eq(x->libdir, std::string(x->march) + "/" + std::string(x->mabi))); // Filling this column does NOT give those targets a C library by // default. It is consulted only once a sysroot has been RESOLVED, and the // target table still binds none for these two rows — the zero-libc tier // stays the default and the package stays opt-in. auto t = mcpp::toolchain::triple::parse("aarch64-none-elf"); - ASSERT_TRUE(t.has_value()); - EXPECT_TRUE(mcpp::toolchain::triple::effective_sysroot(*t, nullptr).empty()); -} + expect(fatal(t.has_value())); + expect(mcpp::toolchain::triple::effective_sysroot(*t, nullptr).empty()); +}}; // ── The two tables keyed on the same triple must agree ─────────────────────── // @@ -559,28 +559,28 @@ TEST(FreestandingTarget, MultilibDirectoriesMatchTheIndexPayloads) { // The check is possible only because both are compile-time tables with a // single read point each. It costs fifteen lines and removes an entire class // of "added a target, forgot half of it". -TEST(FreestandingTarget, EveryBareRowInTheTargetTableHasAnIsaProfile) { +UTest every_bare_row_in_the_target_table_has_an_isa_profile{"FreestandingTarget/EveryBareRowInTheTargetTableHasAnIsaProfile", [] { for (const auto& info : mcpp::toolchain::triple::known_targets()) { if (info.note != "bare") continue; auto t = mcpp::toolchain::triple::parse(info.canonical); - ASSERT_TRUE(t.has_value()) << info.canonical; - EXPECT_TRUE(mcpp::freestanding::resolve(*t).has_value()) + expect(fatal(t.has_value())) << info.canonical; + expect(mcpp::freestanding::resolve(*t).has_value()) << info.canonical << " is a bare row in kKnownTargets with no row in freestanding::kTable"; } -} +}}; -TEST(FreestandingTarget, EveryIsaProfileHasABareRowInTheTargetTable) { +UTest every_isa_profile_has_a_bare_row_in_the_target_table{"FreestandingTarget/EveryIsaProfileHasABareRowInTheTargetTable", [] { for (const auto& spec : mcpp::freestanding::known()) { auto t = mcpp::toolchain::triple::parse(spec.triple); - ASSERT_TRUE(t.has_value()) << spec.triple; + expect(fatal(t.has_value())) << spec.triple; const auto* info = mcpp::toolchain::triple::find_known_target(*t); - ASSERT_NE(info, nullptr) + expect(fatal(ne(info, nullptr))) << spec.triple << " has an ISA profile but no row in kKnownTargets"; - EXPECT_EQ(info->note, "bare") + expect(eq(info->note, "bare")) << spec.triple << " has an ISA profile but is not marked bare"; } -} +}}; // ── Cortex-M ──────────────────────────────────────────────────────────────── // @@ -612,17 +612,17 @@ bool has_flag(const mcpp::freestanding::Spec& s, std::string_view f) { // floats cross a call boundary, not whether the compiler may use the FPU inside // one — and `thumbv7em` implies an FPU. Measured on llvm 22.1.8: without // `-mfpu=none` the soft row emits `vmul.f32`. -TEST(FreestandingTarget, SoftFloatArm32RowsDisableTheFpu) { +UTest soft_float_arm32_rows_disable_the_fpu{"FreestandingTarget/SoftFloatArm32RowsDisableTheFpu", [] { int soft = 0, hard = 0; for (const auto& spec : mcpp::freestanding::known()) { if (!is_arm32(spec.triple)) continue; if (spec.triple.ends_with("-eabihf")) { ++hard; - EXPECT_FALSE(has_flag(spec, "-mfpu=none")) + expect(!has_flag(spec, "-mfpu=none")) << spec.triple << " is a hard-float row and must not discard the FPU"; } else { ++soft; - EXPECT_TRUE(has_flag(spec, "-mfpu=none")) + expect(has_flag(spec, "-mfpu=none")) << spec.triple << " is a soft-float row without -mfpu=none: it will emit FPU" " instructions that fault on a part with no FPU"; @@ -630,47 +630,45 @@ TEST(FreestandingTarget, SoftFloatArm32RowsDisableTheFpu) { } // A denominator, because a loop that never ran satisfies every EXPECT // above. Both halves must be non-empty for the contrast to mean anything. - EXPECT_GT(soft, 0); - EXPECT_GT(hard, 0); + expect(gt(soft, 0)); + expect(gt(hard, 0)); // And the A-profile rows are in the denominator, which is the half a // spelling-based predicate lost without failing. int a_profile = 0; for (const auto& spec : mcpp::freestanding::known()) if (spec.triple.starts_with("armv")) ++a_profile; - EXPECT_GT(a_profile, 0); -} + expect(gt(a_profile, 0)); +}}; // 32-bit ARM has no `-mcmodel`, and clang has a BareMetal toolchain for arm so // its driver reaches `ld.lld` without the x86_64 row's workaround. Both columns // being empty is what says so; a filled one would be a value nothing checks. -TEST(FreestandingTarget, Arm32RowsNeedNoCodeModelAndNoDirectLldDriving) { +UTest arm32_rows_need_no_code_model_and_no_direct_lld_driving{"FreestandingTarget/Arm32RowsNeedNoCodeModelAndNoDirectLldDriving", [] { int rows = 0; for (const auto& spec : mcpp::freestanding::known()) { if (!is_arm32(spec.triple)) continue; ++rows; - EXPECT_TRUE(spec.mcmodel.empty()) << spec.triple << " has no -mcmodel axis"; - EXPECT_TRUE(spec.lldEmulation.empty()) + expect(spec.mcmodel.empty()) << spec.triple << " has no -mcmodel axis"; + expect(spec.lldEmulation.empty()) << spec.triple << " reaches ld.lld through the clang driver"; - EXPECT_EQ(spec.mabi, "aapcs") << spec.triple; + expect(eq(spec.mabi, "aapcs")) << spec.triple; } - EXPECT_GT(rows, 0); -} + expect(gt(rows, 0)); +}}; // The compile half of `--gc-sections`, which has to hold for EVERY freestanding // target rather than only the M-profile ones: a dependency's translation units // carry these flags too, and a project cannot reach those. -TEST(FreestandingTarget, EveryRowCompilesWithPerFunctionSections) { +UTest every_row_compiles_with_per_function_sections{"FreestandingTarget/EveryRowCompilesWithPerFunctionSections", [] { int rows = 0; for (const auto& spec : mcpp::freestanding::known()) { ++rows; auto flags = mcpp::freestanding::compile_flags(spec); - EXPECT_NE(std::find(flags.begin(), flags.end(), "-ffunction-sections"), - flags.end()) << spec.triple; - EXPECT_NE(std::find(flags.begin(), flags.end(), "-fdata-sections"), - flags.end()) << spec.triple; + expect(ne(std::find(flags.begin(), flags.end(), "-ffunction-sections"), flags.end())) << spec.triple; + expect(ne(std::find(flags.begin(), flags.end(), "-fdata-sections"), flags.end())) << spec.triple; } - EXPECT_GT(rows, 0); -} + expect(gt(rows, 0)); +}}; // THE MULTILIB KEY IS NOT `/` ON ARM, AND ASSUMING IT WAS // PRODUCES AN ABI SUBSTITUTION THAT NOTHING REPORTS. @@ -689,30 +687,30 @@ TEST(FreestandingTarget, EveryRowCompilesWithPerFunctionSections) { // So the assertion is not "libdir is non-empty": it is that the soft and hard // variants of ONE architecture name DIFFERENT directories, which is the // property the layout exists to preserve. -TEST(FreestandingTarget, Arm32SoftAndHardVariantsNameDifferentLibdirs) { +UTest arm32_soft_and_hard_variants_name_different_libdirs{"FreestandingTarget/Arm32SoftAndHardVariantsNameDifferentLibdirs", [] { std::map> byMarch; int rows = 0; for (const auto& spec : mcpp::freestanding::known()) { if (!is_arm32(spec.triple)) continue; ++rows; - EXPECT_FALSE(spec.libdir.empty()) + expect(!spec.libdir.empty()) << spec.triple << " has no libdir, so an opt-in sysroot cannot be found"; byMarch[std::string(spec.march)].push_back(std::string(spec.libdir)); } - EXPECT_GT(rows, 0); + expect(gt(rows, 0)); int pairs = 0; for (auto const& [march, dirs] : byMarch) { if (dirs.size() < 2) continue; ++pairs; std::set distinct(dirs.begin(), dirs.end()); - EXPECT_EQ(distinct.size(), dirs.size()) + expect(eq(distinct.size(), dirs.size())) << march << " maps " << dirs.size() << " rows onto " << distinct.size() << " directories: a soft-float program would " "link a hard-float library, silently"; } // A denominator. If no architecture had two rows the loop above would // assert nothing, and the property would be untested rather than held. - EXPECT_GT(pairs, 0) << "no architecture carries both float ABIs; the check " + expect(gt(pairs, 0)) << "no architecture carries both float ABIs; the check " "above quantified over nothing"; -} +}}; diff --git a/tests/unit/test_graph_shape.cpp b/tests/unit/test_graph_shape.cpp index b5ea4da73..cdc4ccef7 100644 --- a/tests/unit/test_graph_shape.cpp +++ b/tests/unit/test_graph_shape.cpp @@ -1,7 +1,9 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.graph_shape; +using namespace mcpplibs::mtest; + // The header line build.ninja carries is what the fast paths read before any // plan exists. Four facts ride it -- the graph's shape, the module-edge // schedule, (2026.9.5.3+) whether a `--accel` / `--no-accel` override chose @@ -37,72 +39,65 @@ std::filesystem::path write_graph(const std::string& first) { } // namespace -TEST(GraphShape, TheHeaderNamesShapeScheduleSelectionAndFormat) { - EXPECT_EQ(mcpp::build::header_line(mcpp::build::GraphShape::Normal, "none", false), - "# mcpp:graph=normal;schedule=none;accel=default;dist=none;request="); - EXPECT_EQ(mcpp::build::header_line(mcpp::build::GraphShape::WithTests, "two-phase", true), - "# mcpp:graph=test;schedule=two-phase;accel=override;dist=none;request="); +UTest the_header_names_shape_schedule_selection_and_format{"GraphShape/TheHeaderNamesShapeScheduleSelectionAndFormat", [] { + expect(eq(mcpp::build::header_line(mcpp::build::GraphShape::Normal, "none", false), "# mcpp:graph=normal;schedule=none;accel=default;dist=none;request=")); + expect(eq(mcpp::build::header_line(mcpp::build::GraphShape::WithTests, "two-phase", true), "# mcpp:graph=test;schedule=two-phase;accel=override;dist=none;request=")); // An empty format reads as "none" rather than as an empty field: the value // has to be a word, because `read_pack_format` returning "" already means // "this file predates the field", and the two must not collide. - EXPECT_EQ(mcpp::build::header_line(mcpp::build::GraphShape::Normal, "none", false, "appimage"), - "# mcpp:graph=normal;schedule=none;accel=default;dist=appimage;request="); -} + expect(eq(mcpp::build::header_line(mcpp::build::GraphShape::Normal, "none", false, "appimage"), "# mcpp:graph=normal;schedule=none;accel=default;dist=appimage;request=")); +}}; // Workspace design 2026-09-29 §3: the request a graph was planned for rides // the same line, because the features and the workspace members no longer // name the directory. One tag per request, whatever the spelling. -TEST(GraphShape, TheHeaderNamesTheRequest) { +UTest the_header_names_the_request{"GraphShape/TheHeaderNamesTheRequest", [] { using namespace mcpp::build; const auto tag = request_tag("cli\x1egui", "b, a"); - EXPECT_EQ(tag, request_tag("cli\x1egui", "a,b")); - EXPECT_NE(tag, request_tag("cli", "a,b")); - EXPECT_NE(tag, request_tag("cli\x1egui", "a")); - EXPECT_EQ(read_request(write_graph(header_line(GraphShape::Normal, "none", false, {}, tag))), - tag); - EXPECT_EQ(read_request(write_graph("# mcpp:graph=normal;schedule=none;accel=default;dist=none")), - ""); -} + expect(eq(tag, request_tag("cli\x1egui", "a,b"))); + expect(ne(tag, request_tag("cli", "a,b"))); + expect(ne(tag, request_tag("cli\x1egui", "a"))); + expect(eq(read_request(write_graph(header_line(GraphShape::Normal, "none", false, {}, tag))), tag)); + expect(eq(read_request(write_graph("# mcpp:graph=normal;schedule=none;accel=default;dist=none")), "")); +}}; -TEST(GraphShape, OnlyAPlainGraphWithTheManifestsVariantIsReplayed) { +UTest only_a_plain_graph_with_the_manifests_variant_is_replayed{"GraphShape/OnlyAPlainGraphWithTheManifestsVariantIsReplayed", [] { using namespace mcpp::build; - EXPECT_TRUE(is_plain_build_graph(write_graph(header_line(GraphShape::Normal, "none", false)))); + expect(is_plain_build_graph(write_graph(header_line(GraphShape::Normal, "none", false)))); // An override chose this variant: a plain build must not replay it. - EXPECT_FALSE(is_plain_build_graph(write_graph(header_line(GraphShape::Normal, "none", true)))); + expect(!(is_plain_build_graph(write_graph(header_line(GraphShape::Normal, "none", true))))); // The test-shaped graph was already refused. - EXPECT_FALSE(is_plain_build_graph(write_graph(header_line(GraphShape::WithTests, "none", false)))); + expect(!(is_plain_build_graph(write_graph(header_line(GraphShape::WithTests, "none", false))))); // A DISTRIBUTION EDGE IS NOT PART OF A PLAIN BUILD. `mcpp pack --format // appimage` makes a build program submit an artifact action consuming the // staged tree; replaying that graph for a plain build would produce a // distributable as a side effect of `mcpp build`, from a staged tree that // is no longer guaranteed to describe this build. - EXPECT_FALSE(is_plain_build_graph( - write_graph(header_line(GraphShape::Normal, "none", false, "appimage")))); - EXPECT_EQ(read_pack_format( - write_graph(header_line(GraphShape::Normal, "none", false, "appimage"))), - "appimage"); - EXPECT_EQ(read_pack_format( - write_graph(header_line(GraphShape::Normal, "none", false))), - "none"); -} + expect(!(is_plain_build_graph( + write_graph(header_line(GraphShape::Normal, "none", false, "appimage"))))); + expect(eq(read_pack_format( + write_graph(header_line(GraphShape::Normal, "none", false, "appimage"))), "appimage")); + expect(eq(read_pack_format( + write_graph(header_line(GraphShape::Normal, "none", false))), "none")); +}}; -TEST(GraphShape, AGraphThatPredatesTheFieldIsAMiss) { +UTest a_graph_that_predates_the_field_is_a_miss{"GraphShape/AGraphThatPredatesTheFieldIsAMiss", [] { using namespace mcpp::build; // Written by a 2026.9.5.2 mcpp: shape and schedule, no selection. It is // not known to be the manifest's variant, so it is prepared once more and // rewritten with the field -- a miss, never a guess. auto p = write_graph("# mcpp:graph=normal;schedule=none"); - EXPECT_EQ(read_shape(p), GraphShape::Normal); - EXPECT_EQ(read_accel_selection(p), ""); - EXPECT_EQ(read_pack_format(p), ""); - EXPECT_FALSE(is_plain_build_graph(p)); + expect(eq(read_shape(p), GraphShape::Normal)); + expect(eq(read_accel_selection(p), "")); + expect(eq(read_pack_format(p), "")); + expect(!is_plain_build_graph(p)); // Written by a 2026.9.10.2 mcpp: shape, schedule and selection, no // distribution field. Same rule one field later -- absent is a miss, and // must not be read as "none", or the very first build after an upgrade // would replay a graph this binary cannot describe. auto q = write_graph("# mcpp:graph=normal;schedule=none;accel=default"); - EXPECT_EQ(read_accel_selection(q), "default"); - EXPECT_EQ(read_pack_format(q), ""); - EXPECT_FALSE(is_plain_build_graph(q)); -} + expect(eq(read_accel_selection(q), "default")); + expect(eq(read_pack_format(q), "")); + expect(!is_plain_build_graph(q)); +}}; diff --git a/tests/unit/test_hermetic_graph_link.cpp b/tests/unit/test_hermetic_graph_link.cpp index f633179eb..96ee6e5b1 100644 --- a/tests/unit/test_hermetic_graph_link.cpp +++ b/tests/unit/test_hermetic_graph_link.cpp @@ -6,13 +6,15 @@ // (the check covers sandbox toolchains only) printing one linker line with the // search directories each case needs. The check itself is Linux-only. -#include import std; +import mcpplibs.mtest; import mcpp.build.hermetic; import mcpplibs.os.platform; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + namespace { struct Tmp { @@ -52,32 +54,31 @@ mcpp::toolchain::Toolchain fake_driver(const std::filesystem::path& root, } // namespace -TEST(HermeticGraphLink, AHostSearchDirectoryIsRefusedAndNamed) { - if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "the check runs on Linux hosts"; +UTest a_host_search_directory_is_refused_and_named{"HermeticGraphLink/AHostSearchDirectoryIsRefusedAndNamed", [] { + if constexpr (!mcpplibs::os::is_linux) skip("the check runs on Linux hosts"); Tmp t; auto tc = fake_driver(t.path, {"/usr/lib", (t.path / "xpkgs" / "musl" / "lib").string()}); auto r = mcpp::build::verify_hermetic_link(tc, "", t.path / "out", false, /*isolatedGraphLink=*/true, {}); - ASSERT_FALSE(r.has_value()); - EXPECT_NE(r.error().find("/usr/lib"), std::string::npos) << r.error(); - EXPECT_NE(r.error().find("mcpp#696"), std::string::npos) << r.error(); - EXPECT_EQ(r.error().find("xpkgs/musl"), std::string::npos) - << "a store directory is allowed and must not be named: " << r.error(); -} + expect(fatal(!r.has_value())); + if (auto&& failed = expect(ne(r.error().find("/usr/lib"), std::string::npos)); !failed) failed << r.error(); + if (auto&& failed = expect(ne(r.error().find("mcpp#696"), std::string::npos)); !failed) failed << r.error(); + if (auto&& failed = expect(eq(r.error().find("xpkgs/musl"), std::string::npos)); !failed) failed << "a store directory is allowed and must not be named: " << r.error(); +}}; -TEST(HermeticGraphLink, AllowHostLibsDowngradesTheRefusal) { - if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "the check runs on Linux hosts"; +UTest allow_host_libs_downgrades_the_refusal{"HermeticGraphLink/AllowHostLibsDowngradesTheRefusal", [] { + if constexpr (!mcpplibs::os::is_linux) skip("the check runs on Linux hosts"); Tmp t; auto tc = fake_driver(t.path, {"/usr/lib"}); auto r = mcpp::build::verify_hermetic_link(tc, "", t.path / "out", true, /*isolatedGraphLink=*/true, {}); - EXPECT_TRUE(r.has_value()) << (r ? "" : r.error()); -} + if (auto&& failed = expect(r.has_value()); !failed) failed << (r ? "" : r.error()); +}}; // The build directory holds the empty graph sysroot, and a package may add a // package-relative `-L` whose root is anywhere (a path dependency). -TEST(HermeticGraphLink, TheBuildAndTheGraphsPackagesAreAllowed) { - if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "the check runs on Linux hosts"; +UTest the_build_and_the_graphs_packages_are_allowed{"HermeticGraphLink/TheBuildAndTheGraphsPackagesAreAllowed", [] { + if constexpr (!mcpplibs::os::is_linux) skip("the check runs on Linux hosts"); Tmp t; const auto out = t.path / "out"; const auto pathDep = t.path / "elsewhere" / "openkal-musl"; @@ -86,16 +87,16 @@ TEST(HermeticGraphLink, TheBuildAndTheGraphsPackagesAreAllowed) { (t.path / "xpkgs" / "musl" / "lib").string()}); auto r = mcpp::build::verify_hermetic_link(tc, "", out, false, /*isolatedGraphLink=*/true, {pathDep}); - EXPECT_TRUE(r.has_value()) << (r ? "" : r.error()); -} + if (auto&& failed = expect(r.has_value()); !failed) failed << (r ? "" : r.error()); +}}; // A payload link keeps its payload's directories; the `-L` rule is for the // isolated graph link only. -TEST(HermeticGraphLink, APayloadLinkIsNotHeldToTheGraphRule) { - if constexpr (!mcpplibs::os::is_linux) GTEST_SKIP() << "the check runs on Linux hosts"; +UTest a_payload_link_is_not_held_to_the_graph_rule{"HermeticGraphLink/APayloadLinkIsNotHeldToTheGraphRule", [] { + if constexpr (!mcpplibs::os::is_linux) skip("the check runs on Linux hosts"); Tmp t; auto tc = fake_driver(t.path, {"/usr/lib"}); auto r = mcpp::build::verify_hermetic_link(tc, "", t.path / "out", false, /*isolatedGraphLink=*/false, {}); - EXPECT_TRUE(r.has_value()) << (r ? "" : r.error()); -} + if (auto&& failed = expect(r.has_value()); !failed) failed << (r ? "" : r.error()); +}}; diff --git a/tests/unit/test_home.cpp b/tests/unit/test_home.cpp index 872cb479a..690242c6d 100644 --- a/tests/unit/test_home.cpp +++ b/tests/unit/test_home.cpp @@ -1,7 +1,7 @@ -#include #include import std; +import mcpplibs.mtest; import mcpp.invocation; import mcpp.home; import mcpplibs.os.platform; @@ -10,6 +10,8 @@ import mcpp.toolchain.stdmod; import mcpp.config; import mcpp.libs.toml; +using namespace mcpplibs::mtest; + namespace { // Set/restore an environment variable for the duration of a test. @@ -23,79 +25,75 @@ class ScopedEnv { } // namespace -TEST(Home, ExplicitMcppHomeWins) { +UTest explicit_mcpp_home_wins{"Home/ExplicitMcppHomeWins", [] { ScopedEnv home("MCPP_HOME", "/tmp/mcpp-home-test"); const auto expected = std::filesystem::weakly_canonical(std::filesystem::absolute("/tmp/mcpp-home-test")); - EXPECT_EQ(mcpp::home::root(), expected); - EXPECT_EQ(mcpp::home::cache_root(), - expected / "build-cache" / "v1"); -} + expect(eq(mcpp::home::root(), expected)); + expect(eq(mcpp::home::cache_root(), expected / "build-cache" / "v1")); +}}; -TEST(Home, CacheRootIsAlwaysRootSlashBuildCacheV1) { +UTest cache_root_is_always_root_slash_build_cache_v1{"Home/CacheRootIsAlwaysRootSlashBuildCacheV1", [] { ScopedEnv home("MCPP_HOME", "/tmp/mcpp-home-test-2"); - EXPECT_EQ(mcpp::home::cache_root(), mcpp::home::root() / "build-cache" / "v1"); -} + expect(eq(mcpp::home::cache_root(), mcpp::home::root() / "build-cache" / "v1")); +}}; // The layout version has to be a path segment, not an implicit convention: // replacing the layout must never require migrating or deleting the old tree. -TEST(Home, CacheRootCarriesTheLayoutVersion) { +UTest cache_root_carries_the_layout_version{"Home/CacheRootCarriesTheLayoutVersion", [] { ScopedEnv home("MCPP_HOME", "/tmp/mcpp-home-test-2b"); - EXPECT_EQ(mcpp::home::cache_root().filename(), std::filesystem::path("v1")); - EXPECT_EQ(mcpp::home::cache_root().parent_path().filename(), - std::filesystem::path("build-cache")); + expect(eq(mcpp::home::cache_root().filename(), std::filesystem::path("v1"))); + expect(eq(mcpp::home::cache_root().parent_path().filename(), std::filesystem::path("build-cache"))); // Must NOT nest inside /cache: GlobalConfig's index metadata cache // owns that name and its reset path removes the whole directory. - EXPECT_NE(mcpp::home::cache_root().parent_path().filename(), - std::filesystem::path("cache")); + expect(ne(mcpp::home::cache_root().parent_path().filename(), std::filesystem::path("cache"))); // The pre-v1 root must stay resolvable and must be a DIFFERENT tree, so // `doctor` can report it and `cache clean --legacy` can reclaim it. - EXPECT_EQ(mcpp::home::legacy_bmi_root(), mcpp::home::root() / "bmi"); - EXPECT_NE(mcpp::home::legacy_bmi_root(), mcpp::home::cache_root()); -} + expect(eq(mcpp::home::legacy_bmi_root(), mcpp::home::root() / "bmi")); + expect(ne(mcpp::home::legacy_bmi_root(), mcpp::home::cache_root())); +}}; // The user-home fallback must land on `.mcpp`, never on the legacy flat // `.mcpp-bmi` sibling that the pre-#311 std-module resolver produced whenever // neither MCPP_HOME nor HOME resolved (the default on Windows PowerShell). -TEST(Home, FallbackUsesDotMcppNotLegacyFlatDir) { +UTest fallback_uses_dot_mcpp_not_legacy_flat_dir{"Home/FallbackUsesDotMcppNotLegacyFlatDir", [] { ScopedEnv mcppHome("MCPP_HOME", nullptr); ScopedEnv userHome("HOME", nullptr); #if defined(_WIN32) ScopedEnv profile("USERPROFILE", nullptr); #endif auto root = mcpp::home::root(); - EXPECT_EQ(root.filename(), std::filesystem::path(".mcpp")); - EXPECT_NE(mcpp::home::cache_root().filename(), - std::filesystem::path(".mcpp-bmi")); -} + expect(eq(root.filename(), std::filesystem::path(".mcpp"))); + expect(ne(mcpp::home::cache_root().filename(), std::filesystem::path(".mcpp-bmi"))); +}}; // #311's D2 regression gate: the std module cache and the dep cache must // resolve to ONE root. `default_cache_root()` used to be a private copy of the // home resolution that drifted (no USERPROFILE, no self-contained detection), // so on Windows the std BMI landed in the current working directory. -TEST(Home, StdModuleCacheRootEqualsHomeCacheRoot) { +UTest std_module_cache_root_equals_home_cache_root{"Home/StdModuleCacheRootEqualsHomeCacheRoot", [] { { ScopedEnv home("MCPP_HOME", "/tmp/mcpp-home-test-3"); - EXPECT_EQ(mcpp::toolchain::default_cache_root(), mcpp::home::cache_root()); + expect(eq(mcpp::toolchain::default_cache_root(), mcpp::home::cache_root())); } { ScopedEnv mcppHome("MCPP_HOME", nullptr); - EXPECT_EQ(mcpp::toolchain::default_cache_root(), mcpp::home::cache_root()); + expect(eq(mcpp::toolchain::default_cache_root(), mcpp::home::cache_root())); } -} +}}; namespace { -class HomeSelection : public ::testing::Test { +class HomeSelection { protected: std::filesystem::path scratch = std::filesystem::temp_directory_path() / std::format("mcpp-home-{}", std::chrono::steady_clock::now().time_since_epoch().count()); std::filesystem::path original = scratch / "original"; std::filesystem::path target = scratch / "target"; ScopedEnv env{"MCPP_HOME", mcpp::home::spelling(original).c_str()}; - void SetUp() override { + void SetUp() { std::filesystem::create_directories(original); std::filesystem::create_directories(target); } - void TearDown() override { + void TearDown() { std::error_code ec; std::filesystem::remove_all(scratch, ec); } @@ -106,111 +104,185 @@ class HomeSelection : public ::testing::Test { }; } -TEST_F(HomeSelection, DefaultOriginalAndEffectiveAreIdentical) { +UTest default_original_and_effective_are_identical{"HomeSelection/DefaultOriginalAndEffectiveAreIdentical", [] { + struct Case : HomeSelection { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { const auto selected = mcpp::home::selection(); - EXPECT_EQ(selected.original, std::filesystem::weakly_canonical(original)); - EXPECT_EQ(selected.original, selected.effective); - EXPECT_FALSE(selected.redirected); + expect(eq(selected.original, std::filesystem::weakly_canonical(original))); + expect(eq(selected.original, selected.effective)); + expect(!selected.redirected); } + } fixture; + fixture.body(); +}}; -TEST_F(HomeSelection, EnvironmentSelectsTheLocatorAndOnlyOneHopIsRead) { +UTest environment_selects_the_locator_and_only_one_hop_is_read{"HomeSelection/EnvironmentSelectsTheLocatorAndOnlyOneHopIsRead", [] { + struct Case : HomeSelection { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { const auto third = scratch / "third"; std::filesystem::create_directories(third); locator(original, target); locator(target, third); const auto selected = mcpp::home::selection(); - EXPECT_EQ(selected.original, std::filesystem::weakly_canonical(original)); - EXPECT_EQ(selected.effective, std::filesystem::weakly_canonical(target)); - EXPECT_EQ(mcpp::home::cache_root(), selected.effective / "build-cache" / "v1"); - EXPECT_TRUE(selected.redirected); + expect(eq(selected.original, std::filesystem::weakly_canonical(original))); + expect(eq(selected.effective, std::filesystem::weakly_canonical(target))); + expect(eq(mcpp::home::cache_root(), selected.effective / "build-cache" / "v1")); + expect(bool(selected.redirected)); } + } fixture; + fixture.body(); +}}; -TEST_F(HomeSelection, UnavailableTargetIsAnErrorInsteadOfAFallback) { +UTest unavailable_target_is_an_error_instead_of_a_fallback{"HomeSelection/UnavailableTargetIsAnErrorInsteadOfAFallback", [] { + struct Case : HomeSelection { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { locator(original, scratch / "missing"); const auto selected = mcpp::home::inspect(original); - ASSERT_FALSE(selected); - EXPECT_NE(selected.error().find("unavailable"), std::string::npos); - EXPECT_FALSE(std::filesystem::exists(original / "config.toml")); + expect(fatal(!selected)); + expect(ne(selected.error().find("unavailable"), std::string::npos)); + expect(!std::filesystem::exists(original / "config.toml")); } + } fixture; + fixture.body(); +}}; -TEST_F(HomeSelection, ForceCanRepairAMalformedLocator) { +UTest force_can_repair_a_malformed_locator{"HomeSelection/ForceCanRepairAMalformedLocator", [] { + struct Case : HomeSelection { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { std::ofstream(original / "home.toml") << "home = [\n"; - EXPECT_FALSE(mcpp::home::choose(target, false)); + expect(!mcpp::home::choose(target, false)); const auto selected = mcpp::home::choose(target, true); - ASSERT_TRUE(selected) << selected.error(); - EXPECT_TRUE(mcpp::home::save(*selected)); + if (auto&& failed = expect(fatal(bool(selected))); !failed) failed << selected.error(); + expect(bool(mcpp::home::save(*selected))); const auto restored = mcpp::home::inspect(original); - ASSERT_TRUE(restored) << restored.error(); - EXPECT_EQ(restored->effective, std::filesystem::weakly_canonical(target)); + if (auto&& failed = expect(fatal(bool(restored))); !failed) failed << restored.error(); + expect(eq(restored->effective, std::filesystem::weakly_canonical(target))); } + } fixture; + fixture.body(); +}}; -TEST_F(HomeSelection, ExistingHomeNeedsForceAndChangingItKeepsBothDataTrees) { +UTest existing_home_needs_force_and_changing_it_keeps_both_data_trees{"HomeSelection/ExistingHomeNeedsForceAndChangingItKeepsBothDataTrees", [] { + struct Case : HomeSelection { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { std::ofstream(original / "config.toml") << "# existing\n"; std::ofstream(target / "sentinel") << "keep\n"; - EXPECT_FALSE(mcpp::home::choose(target, false)); + expect(!mcpp::home::choose(target, false)); const auto selected = mcpp::home::choose(target, true); - ASSERT_TRUE(selected) << selected.error(); - ASSERT_TRUE(mcpp::home::save(*selected)); - EXPECT_TRUE(std::filesystem::exists(original / "config.toml")); - EXPECT_TRUE(std::filesystem::exists(target / "sentinel")); - EXPECT_EQ(mcpp::invocation::get("MCPP_HOME").value_or(""), mcpp::home::spelling(original)); + if (auto&& failed = expect(fatal(bool(selected))); !failed) failed << selected.error(); + expect(fatal(bool(mcpp::home::save(*selected)))); + expect(std::filesystem::exists(original / "config.toml")); + expect(std::filesystem::exists(target / "sentinel")); + expect(eq(mcpp::invocation::get("MCPP_HOME").value_or(""), mcpp::home::spelling(original))); const auto restored = mcpp::home::inspect(original); - ASSERT_TRUE(restored); - EXPECT_EQ(restored->previous, std::filesystem::weakly_canonical(original)); + expect(fatal(bool(restored))); + expect(eq(restored->previous, std::filesystem::weakly_canonical(original))); } + } fixture; + fixture.body(); +}}; -TEST_F(HomeSelection, ReturningToOriginalRemovesOnlyTheLocator) { +UTest returning_to_original_removes_only_the_locator{"HomeSelection/ReturningToOriginalRemovesOnlyTheLocator", [] { + struct Case : HomeSelection { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { locator(original, target); std::ofstream(target / "config.toml") << "# existing\n"; const auto selected = mcpp::home::choose(original, true); - ASSERT_TRUE(selected) << selected.error(); - ASSERT_TRUE(mcpp::home::save(*selected)); - EXPECT_FALSE(std::filesystem::exists(original / "home.toml")); - EXPECT_TRUE(std::filesystem::exists(target / "config.toml")); - EXPECT_EQ(mcpp::home::root(), std::filesystem::weakly_canonical(original)); + if (auto&& failed = expect(fatal(bool(selected))); !failed) failed << selected.error(); + expect(fatal(bool(mcpp::home::save(*selected)))); + expect(!std::filesystem::exists(original / "home.toml")); + expect(std::filesystem::exists(target / "config.toml")); + expect(eq(mcpp::home::root(), std::filesystem::weakly_canonical(original))); } + } fixture; + fixture.body(); +}}; -TEST_F(HomeSelection, NestedDirectoriesAreRefused) { - EXPECT_FALSE(mcpp::home::choose(original / "nested", true)); - EXPECT_FALSE(mcpp::home::choose(scratch, true)); +UTest nested_directories_are_refused{"HomeSelection/NestedDirectoriesAreRefused", [] { + struct Case : HomeSelection { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { + expect(!mcpp::home::choose(original / "nested", true)); + expect(!mcpp::home::choose(scratch, true)); } + } fixture; + fixture.body(); +}}; -TEST_F(HomeSelection, ReadOnlyConfigurationDoesNotInitializeOrAcquireAProvider) { +UTest read_only_configuration_does_not_initialize_or_acquire_a_provider{"HomeSelection/ReadOnlyConfigurationDoesNotInitializeOrAcquireAProvider", [] { + struct Case : HomeSelection { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { const auto cold = scratch / "cold"; ScopedEnv home("MCPP_HOME", mcpp::home::spelling(cold).c_str()); const auto config = mcpp::config::load_or_init(true, {}, {}, mcpp::config::Initialization::ReadOnly); - ASSERT_TRUE(config) << config.error().message; - EXPECT_EQ(config->mcppHome, std::filesystem::weakly_canonical(cold)); - EXPECT_FALSE(std::filesystem::exists(cold)); + if (auto&& failed = expect(fatal(bool(config))); !failed) failed << config.error().message; + expect(eq(config->mcppHome, std::filesystem::weakly_canonical(cold))); + expect(!std::filesystem::exists(cold)); } + } fixture; + fixture.body(); +}}; -TEST_F(HomeSelection, UserSourceSurvivesOriginalPathNormalization) { +UTest user_source_survives_original_path_normalization{"HomeSelection/UserSourceSurvivesOriginalPathNormalization", [] { + struct Case : HomeSelection { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { const auto user = mcpp::home::spelling(scratch); ScopedEnv env("MCPP_HOME", nullptr); ScopedEnv posixHome("HOME", user.c_str()); ScopedEnv windowsHome("USERPROFILE", user.c_str()); const auto selected = mcpp::home::inspect(scratch / ".mcpp"); - ASSERT_TRUE(selected) << selected.error(); - EXPECT_EQ(selected->source, "user"); + if (auto&& failed = expect(fatal(bool(selected))); !failed) failed << selected.error(); + expect(eq(selected->source, "user")); } + } fixture; + fixture.body(); +}}; -TEST_F(HomeSelection, MissingUserDirectoryCannotPublishALocatorInTheWorkingDirectory) { +UTest missing_user_directory_cannot_publish_a_locator_in_the_working_directory{"HomeSelection/MissingUserDirectoryCannotPublishALocatorInTheWorkingDirectory", [] { + struct Case : HomeSelection { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { ScopedEnv explicitHome("MCPP_HOME", nullptr); ScopedEnv posixHome("HOME", nullptr); ScopedEnv windowsHome("USERPROFILE", nullptr); const auto selected = mcpp::home::choose(target, true); - ASSERT_FALSE(selected); - EXPECT_NE(selected.error().find("user directory"), std::string::npos); + expect(fatal(!selected)); + expect(ne(selected.error().find("user directory"), std::string::npos)); } + } fixture; + fixture.body(); +}}; -TEST_F(HomeSelection, UnicodeLocatorRoundTripDoesNotNarrowTheSelectedPath) { +UTest unicode_locator_round_trip_does_not_narrow_the_selected_path{"HomeSelection/UnicodeLocatorRoundTripDoesNotNarrowTheSelectedPath", [] { + struct Case : HomeSelection { + Case() { SetUp(); } + ~Case() { TearDown(); } + void body() { if constexpr (mcpplibs::os::is_windows) { const auto acp = mcpplibs::os::windows::code_page(); - ASSERT_NE(acp, 0u); + expect(fatal(ne(acp, 0u))); if (acp == 65001) - GTEST_SKIP() << "UTF-8 ACP cannot prove the legacy-code-page path conversion boundary; " - "the actual CLI Unicode case still runs"; + skip([&] { std::ostringstream text; text << "UTF-8 ACP cannot prove the legacy-code-page path conversion boundary; " + "the actual CLI Unicode case still runs"; return text.str(); }()); } // Devanagari cannot be represented by any legacy Windows ANSI code page. // Explicit UTF-8 code units keep this independent of compiler source encoding. @@ -218,10 +290,13 @@ TEST_F(HomeSelection, UnicodeLocatorRoundTripDoesNotNarrowTheSelectedPath) { std::u8string(u8"selected space \u0915\u0916\u0917 \u65e5\u672c\u8a9e")); std::filesystem::create_directory(unicode); const auto selected = mcpp::home::choose(unicode, true); - ASSERT_TRUE(selected) << selected.error(); - ASSERT_TRUE(mcpp::home::save(*selected)); + if (auto&& failed = expect(fatal(bool(selected))); !failed) failed << selected.error(); + expect(fatal(bool(mcpp::home::save(*selected)))); const auto restored = mcpp::home::inspect(original); - ASSERT_TRUE(restored) << restored.error(); - EXPECT_EQ(restored->effective, std::filesystem::weakly_canonical(unicode)); - EXPECT_EQ(mcpp::home::from_utf8(mcpp::home::spelling(unicode)), unicode); + if (auto&& failed = expect(fatal(bool(restored))); !failed) failed << restored.error(); + expect(eq(restored->effective, std::filesystem::weakly_canonical(unicode))); + expect(eq(mcpp::home::from_utf8(mcpp::home::spelling(unicode)), unicode)); } + } fixture; + fixture.body(); +}}; diff --git a/tests/unit/test_hostflags.cpp b/tests/unit/test_hostflags.cpp index d8ab38e14..efe6b39ba 100644 --- a/tests/unit/test_hostflags.cpp +++ b/tests/unit/test_hostflags.cpp @@ -1,13 +1,15 @@ -#include #include import std; +import mcpplibs.mtest; import mcpplibs.os.platform; import mcpp.toolchain.dialect; import mcpp.toolchain.hostflags; import mcpp.toolchain.linkmodel; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + using mcpp::toolchain::CompilerId; using mcpp::toolchain::HostFlagOptions; @@ -39,29 +41,29 @@ constexpr CompilerId kFamilies[] = { // turns the principle into a compile-and-run check: a family that is missing // a dialect row, a module row, or host flags fails HERE, not at a user's // `mcpp build`. -TEST(HostFlags, EveryFamilyHasCompleteTables) { +UTest every_family_has_complete_tables{"HostFlags/EveryFamilyHasCompleteTables", [] { for (auto id : kFamilies) { auto tc = tc_for(id); const auto& d = mcpp::toolchain::dialect_for(tc); const auto t = mcpp::toolchain::bmi_traits(tc); - EXPECT_FALSE(d.id.empty()); + expect(!d.id.empty()); // Needed to compile a `.mcpp` at all: the extension is unknown to // every driver, so the language has to be forced. - EXPECT_FALSE(d.forceCxxLangArgv.empty()) << d.id; + expect(!d.forceCxxLangArgv.empty()) << d.id; // Needed to name the produced program. - EXPECT_FALSE(d.outputExePrefix.empty()) << d.id; - EXPECT_FALSE(d.objExt.empty()) << d.id; + expect(!d.outputExePrefix.empty()) << d.id; + expect(!d.objExt.empty()) << d.id; // Needed to build and reference the bundled `mcpp` module. - EXPECT_FALSE(t.bmiDir.empty()) << d.id; - EXPECT_FALSE(t.bmiExt.empty()) << d.id; + expect(!t.bmiDir.empty()) << d.id; + expect(!t.bmiExt.empty()) << d.id; } -} +}}; // The producer must answer for every family too — MSVC deliberately returns // nothing (cl.exe finds headers and libs through INCLUDE/LIB, not argv), but // it must be a decision, not a crash or an accident. -TEST(HostFlags, ProducerAnswersForEveryFamily) { +UTest producer_answers_for_every_family{"HostFlags/ProducerAnswersForEveryFamily", [] { HostFlagOptions opt; for (auto id : kFamilies) { auto tc = tc_for(id); @@ -70,15 +72,15 @@ TEST(HostFlags, ProducerAnswersForEveryFamily) { auto link = mcpp::toolchain::host_link_tokens( tc, opt, mcpp::toolchain::no_escape); if (id == CompilerId::MSVC) { - EXPECT_TRUE(compile.empty()); - EXPECT_TRUE(link.empty()); + expect(compile.empty()); + expect(link.empty()); } // No empty tokens anywhere: an empty argv element is an argument the // driver still has to interpret. - for (auto const& t : compile) EXPECT_FALSE(t.empty()); - for (auto const& t : link) EXPECT_FALSE(t.empty()); + for (auto const& t : compile) expect(!t.empty()); + for (auto const& t : link) expect(!t.empty()); } -} +}}; // ── The rendered string must not move ─────────────────────────────────────── // @@ -89,26 +91,24 @@ TEST(HostFlags, ProducerAnswersForEveryFamily) { // detail. This pins it; the first attempt at this refactor moved // `-stdlib=libc++` after the include flags and would have shipped exactly // that invalidation. -TEST(HostFlags, ClangCfgBypassStringIsStable) { +UTest clang_cfg_bypass_string_is_stable{"HostFlags/ClangCfgBypassStringIsStable", [] { mcpp::toolchain::ClangDriverModel dm; dm.hasCfg = true; dm.cxxIncludes = { "/llvm/include/c++/v1", "/llvm/include/tgt/c++/v1" }; - EXPECT_EQ(dm.compile_flags(mcpp::toolchain::no_escape), - " --no-default-config -nostdinc++" + expect(eq(dm.compile_flags(mcpp::toolchain::no_escape), " --no-default-config -nostdinc++" " -isystem/llvm/include/c++/v1" - " -isystem/llvm/include/tgt/c++/v1"); + " -isystem/llvm/include/tgt/c++/v1")); // With the stdlib selection the std module has always asked for, it lands // immediately after -nostdinc++ — not at the end. - EXPECT_EQ(mcpp::toolchain::render_tokens( - dm.compile_tokens(mcpp::toolchain::no_escape, true)), - " --no-default-config -nostdinc++ -stdlib=libc++" + expect(eq(mcpp::toolchain::render_tokens( + dm.compile_tokens(mcpp::toolchain::no_escape, true)), " --no-default-config -nostdinc++ -stdlib=libc++" " -isystem/llvm/include/c++/v1" - " -isystem/llvm/include/tgt/c++/v1"); -} + " -isystem/llvm/include/tgt/c++/v1")); +}}; -TEST(HostFlags, LinkModelStringsAreStable) { +UTest link_model_strings_are_stable{"HostFlags/LinkModelStringsAreStable", [] { mcpp::toolchain::ToolchainLinkModel lm; lm.mode = mcpp::toolchain::CLibMode::PayloadFirst; lm.clangDriver = true; @@ -117,35 +117,32 @@ TEST(HostFlags, LinkModelStringsAreStable) { lm.loader = "/glibc/lib/ld.so"; lm.systemIncludes = { "/glibc/include" }; - EXPECT_EQ(lm.compile_flags(mcpp::toolchain::no_escape), - " -nostdlibinc -isystem/glibc/include"); - EXPECT_EQ(lm.link_flags(mcpp::toolchain::no_escape), - " -B/glibc/lib -L/glibc/lib -Wl,-rpath,/glibc/lib" - " -Wl,--dynamic-linker=/glibc/lib/ld.so"); + expect(eq(lm.compile_flags(mcpp::toolchain::no_escape), " -nostdlibinc -isystem/glibc/include")); + expect(eq(lm.link_flags(mcpp::toolchain::no_escape), " -B/glibc/lib -L/glibc/lib -Wl,-rpath,/glibc/lib" + " -Wl,--dynamic-linker=/glibc/lib/ld.so")); // GCC takes -idirafter so libstdc++'s #include_next wrappers can still // reach libc. lm.clangDriver = false; - EXPECT_EQ(lm.compile_flags(mcpp::toolchain::no_escape), - " -idirafter/glibc/include"); -} + expect(eq(lm.compile_flags(mcpp::toolchain::no_escape), " -idirafter/glibc/include")); +}}; // ── bmi_reference_tokens ──────────────────────────────────────────────────── // // The traits store these for the ninja STRING channel, where one word vs two // makes no difference. argv consumers cannot be that relaxed. -TEST(HostFlags, BmiReferenceSplitsOnlyWhenTheSpellingHasASpace) { +UTest bmi_reference_splits_only_when_the_spelling_has_a_space{"HostFlags/BmiReferenceSplitsOnlyWhenTheSpellingHasASpace", [] { auto gnu = mcpp::toolchain::bmi_reference_tokens( " -fmodule-file=std=", std::filesystem::path("/tmp/std.pcm")); - ASSERT_EQ(gnu.size(), 1u); - EXPECT_EQ(gnu[0], "-fmodule-file=std=/tmp/std.pcm"); + expect(fatal(eq(gnu.size(), 1u))); + expect(eq(gnu[0], "-fmodule-file=std=/tmp/std.pcm")); auto msvc = mcpp::toolchain::bmi_reference_tokens( " /reference std=", std::filesystem::path("/tmp/std.ifc")); - ASSERT_EQ(msvc.size(), 2u); - EXPECT_EQ(msvc[0], "/reference"); - EXPECT_EQ(msvc[1], "std=/tmp/std.ifc"); -} + expect(fatal(eq(msvc.size(), 2u))); + expect(eq(msvc[0], "/reference")); + expect(eq(msvc[1], "std=/tmp/std.ifc")); +}}; // The general invariant, checked for every family: an argv element must never // contain a space. This bug has now appeared three times in the same shape — @@ -153,33 +150,33 @@ TEST(HostFlags, BmiReferenceSplitsOnlyWhenTheSpellingHasASpace) { // a table entry written for the ninja STRING channel was concatenated into an // argv element. cl.exe answers with "could not find module 'std'", which // names neither the flag nor the reason. -TEST(HostFlags, BmiReferencesNeverProduceATokenWithASpace) { +UTest bmi_references_never_produce_a_token_with_a_space{"HostFlags/BmiReferencesNeverProduceATokenWithASpace", [] { for (auto id : kFamilies) { auto tc = tc_for(id); auto t = mcpp::toolchain::bmi_traits(tc); for (auto prefix : { t.stdBmiUsePrefix, t.stdCompatBmiUsePrefix }) { for (auto const& tok : mcpp::toolchain::bmi_reference_tokens( prefix, std::filesystem::path("/tmp/x.bmi"))) { - EXPECT_EQ(tok.find(' '), std::string::npos) + expect(eq(tok.find(' '), std::string::npos)) << "family " << mcpp::toolchain::dialect_for(tc).id << " token: " << tok; } } } -} +}}; // Same invariant for the language-force spelling, which has both a positional // and a per-file form. -TEST(HostFlags, LanguageForceTokensNeverContainASpace) { +UTest language_force_tokens_never_contain_a_space{"HostFlags/LanguageForceTokensNeverContainASpace", [] { for (auto const* d : { &mcpp::toolchain::gnu_dialect(), &mcpp::toolchain::msvc_dialect() }) { for (auto f : d->forceCxxLangArgv) - EXPECT_EQ(f.find(' '), std::string_view::npos) << d->id << ": " << f; + expect(eq(f.find(' '), std::string_view::npos)) << d->id << ": " << f; for (auto f : d->alwaysFlagsArgv) - EXPECT_EQ(f.find(' '), std::string_view::npos) << d->id << ": " << f; - EXPECT_EQ(d->perFileCxxPrefix.find(' '), std::string_view::npos) << d->id; + expect(eq(f.find(' '), std::string_view::npos)) << d->id << ": " << f; + if (auto&& failed = expect(eq(d->perFileCxxPrefix.find(' '), std::string_view::npos)); !failed) failed << d->id; } -} +}}; // ── orphaned_reference (#604) ─────────────────────────────────────────────── // @@ -193,62 +190,61 @@ TEST(HostFlags, LanguageForceTokensNeverContainASpace) { // // The de-duplicator is gone. This is the invariant that says so, and the // diagnostic that would name the cause if it ever returns. -TEST(HostFlags, AnOrphanedModuleReferenceIsDetected) { +UTest an_orphaned_module_reference_is_detected{"HostFlags/AnOrphanedModuleReferenceIsDetected", [] { using mcpp::toolchain::orphaned_reference; // The defect, verbatim: two references appended, one switch surviving. - EXPECT_EQ(orphaned_reference({"cl.exe", "/std:c++20", "/reference", + expect(eq(orphaned_reference({"cl.exe", "/std:c++20", "/reference", "mcpp=C:/b/mcpp.ifc", "rules.sources=C:/b/rules.sources.ifc", - "/c", "build.mcpp"}), - std::optional{"rules.sources=C:/b/rules.sources.ifc"}); + "/c", "build.mcpp"}), std::optional{"rules.sources=C:/b/rules.sources.ifc"})); // The same argv with both switches present is well formed. - EXPECT_FALSE(orphaned_reference({"cl.exe", "/std:c++20", "/reference", + expect(!(orphaned_reference({"cl.exe", "/std:c++20", "/reference", "mcpp=C:/b/mcpp.ifc", "/reference", "rules.sources=C:/b/rules.sources.ifc", - "/c", "build.mcpp"}).has_value()); + "/c", "build.mcpp"}).has_value())); // First position is an orphan too -- there is nothing in front of it. - EXPECT_TRUE(orphaned_reference({"mcpp=C:/b/mcpp.ifc"}).has_value()); -} + expect(orphaned_reference({"mcpp=C:/b/mcpp.ifc"}).has_value()); +}}; // The rule must not fire on the two bare tokens that legitimately appear. -TEST(HostFlags, OrphanRuleAcceptsInputPathsAndOneWordReferences) { +UTest orphan_rule_accepts_input_paths_and_one_word_references{"HostFlags/OrphanRuleAcceptsInputPathsAndOneWordReferences", [] { using mcpp::toolchain::orphaned_reference; // Clang's form is ONE token and carries its own switch, so it can never be // orphaned -- which is exactly why the defect was MSVC-only. - EXPECT_FALSE(orphaned_reference( + expect(!(orphaned_reference( {"clang++", "-fmodule-file=mcpp=/b/mcpp.pcm", "-fmodule-file=rules.sources=/b/rules.sources.pcm", - "-c", "build.mcpp"}).has_value()); + "-c", "build.mcpp"}).has_value())); // GCC names nothing at all. - EXPECT_FALSE(orphaned_reference( - {"g++", "-fmodules", "-fmodules", "-c", "build.mcpp"}).has_value()); + expect(!(orphaned_reference( + {"g++", "-fmodules", "-fmodules", "-c", "build.mcpp"}).has_value())); // A source or object path that happens to contain `=` is an input, not a // reference: its `=` comes AFTER a directory separator. Without this the // rule would refuse a legal build in a directory a user is allowed to name. - EXPECT_FALSE(orphaned_reference( - {"g++", "/home/me/a=b/build.mcpp"}).has_value()); - EXPECT_FALSE(orphaned_reference( - {"cl.exe", "C:/b/x=y/build.mcpp"}).has_value()); -} + expect(!(orphaned_reference( + {"g++", "/home/me/a=b/build.mcpp"}).has_value())); + expect(!(orphaned_reference( + {"cl.exe", "C:/b/x=y/build.mcpp"}).has_value())); +}}; -TEST(HostFlags, BmiReferenceIsEmptyForAToolchainThatNamesNothing) { +UTest bmi_reference_is_empty_for_a_toolchain_that_names_nothing{"HostFlags/BmiReferenceIsEmptyForAToolchainThatNamesNothing", [] { // GCC finds BMIs implicitly under /gcm.cache — its prefix is empty // and must not produce a stray token. - EXPECT_TRUE(mcpp::toolchain::bmi_reference_tokens( + expect(mcpp::toolchain::bmi_reference_tokens( "", std::filesystem::path("/tmp/x.gcm")).empty()); -} +}}; // ── HostFlagOptions divergences ───────────────────────────────────────────── // // These knobs encode documented differences between the three consumers. A // test so that "why is this optional?" has an answer in code, not only prose. -TEST(HostFlags, CfgBypassLinuxOnlyDiffersFromAlwaysOffLinux) { +UTest cfg_bypass_linux_only_differs_from_always_off_linux{"HostFlags/CfgBypassLinuxOnlyDiffersFromAlwaysOffLinux", [] { auto tc = tc_for(CompilerId::Clang); HostFlagOptions always; always.cfgBypass = HostFlagOptions::CfgBypass::Always; HostFlagOptions linuxOnly; linuxOnly.cfgBypass = HostFlagOptions::CfgBypass::LinuxOnly; @@ -257,10 +253,9 @@ TEST(HostFlags, CfgBypassLinuxOnlyDiffersFromAlwaysOffLinux) { // empty here; the assertion that matters is that the option exists and is // honoured identically on Linux, where the host helper does bypass. if constexpr (mcpplibs::os::is_linux) { - EXPECT_EQ(mcpp::toolchain::host_compile_tokens(tc, always, mcpp::toolchain::no_escape), - mcpp::toolchain::host_compile_tokens(tc, linuxOnly, mcpp::toolchain::no_escape)); + expect(eq(mcpp::toolchain::host_compile_tokens(tc, always, mcpp::toolchain::no_escape), mcpp::toolchain::host_compile_tokens(tc, linuxOnly, mcpp::toolchain::no_escape))); } -} +}}; // ── Which exit of host_link_tokens names the toolchain's runtime dirs ────── // @@ -306,7 +301,7 @@ bool names_dir(const std::vector& tokens, std::string_view dir) { } // namespace -TEST(HostFlags, OnlyTheSpelledOutExitNamesTheToolchainRuntimeDirs) { +UTest only_the_spelled_out_exit_names_the_toolchain_runtime_dirs{"HostFlags/OnlyTheSpelledOutExitNamesTheToolchainRuntimeDirs", [] { FakeClangPayload payload{"runtime-dirs"}; auto tc = tc_for(CompilerId::Clang); tc.binaryPath = payload.root / "bin" / "clang++"; @@ -318,13 +313,13 @@ TEST(HostFlags, OnlyTheSpelledOutExitNamesTheToolchainRuntimeDirs) { // Spelled out: the payload's own runtime directories are named. opt.cfgBypass = HostFlagOptions::CfgBypass::Always; auto spelled = mcpp::toolchain::host_link_tokens(tc, opt, mcpp::toolchain::no_escape); - EXPECT_TRUE(names_dir(spelled, (payload.root / "lib").string())); + expect(bool(names_dir(spelled, (payload.root / "lib").string()))); // Trusting the cfg: they are NOT, and the comment above says what naming // them costs. opt.cfgBypass = HostFlagOptions::CfgBypass::Never; auto trusting = mcpp::toolchain::host_link_tokens(tc, opt, mcpp::toolchain::no_escape); - EXPECT_FALSE(names_dir(trusting, (payload.root / "lib").string())) + expect(!(names_dir(trusting, (payload.root / "lib").string()))) << "the cfg-trusting exit now points `-lc++` at the toolchain's own " "dylib; on Mach-O that link fails on the C++ ABI symbols the system " "libc++ re-exports"; @@ -333,8 +328,8 @@ TEST(HostFlags, OnlyTheSpelledOutExitNamesTheToolchainRuntimeDirs) { opt.runtimeLibDirs = false; opt.cfgBypass = HostFlagOptions::CfgBypass::Always; auto off = mcpp::toolchain::host_link_tokens(tc, opt, mcpp::toolchain::no_escape); - EXPECT_FALSE(names_dir(off, (payload.root / "lib").string())); -} + expect(!(names_dir(off, (payload.root / "lib").string()))); +}}; // TARGET-KEYED, NOT HOST-KEYED (mcpp#685). This used to read // `EXPECT_EQ(found, mcpplibs::os::is_macos)` — the flag's presence @@ -344,7 +339,7 @@ TEST(HostFlags, OnlyTheSpelledOutExitNamesTheToolchainRuntimeDirs) { // non-Apple host silently dropped the flag (and the iOS mirror: a macOS host // building `x86_64-linux-musl` wrongly kept it). Both targets are asked here // so the assertion holds independent of whichever host runs this suite. -TEST(HostFlags, DeploymentTargetOnlyOnAMacosTarget) { +UTest deployment_target_only_on_a_macos_target{"HostFlags/DeploymentTargetOnlyOnAMacosTarget", [] { HostFlagOptions opt; opt.macosDeploymentTarget = "14.0"; @@ -352,7 +347,7 @@ TEST(HostFlags, DeploymentTargetOnlyOnAMacosTarget) { macTc.targetTriple = "aarch64-macos"; auto macTokens = mcpp::toolchain::host_compile_tokens( macTc, opt, mcpp::toolchain::no_escape); - EXPECT_TRUE(std::ranges::any_of(macTokens, [](auto const& t) { + expect(std::ranges::any_of(macTokens, [](auto const& t) { return t == "-mmacosx-version-min=14.0"; })) << "a macOS target must carry the resolved deployment target " "regardless of the host running the build"; @@ -361,11 +356,11 @@ TEST(HostFlags, DeploymentTargetOnlyOnAMacosTarget) { linuxTc.targetTriple = "x86_64-linux-musl"; auto linuxTokens = mcpp::toolchain::host_compile_tokens( linuxTc, opt, mcpp::toolchain::no_escape); - EXPECT_FALSE(std::ranges::any_of(linuxTokens, [](auto const& t) { + expect(!(std::ranges::any_of(linuxTokens, [](auto const& t) { return t.starts_with("-mmacosx-version-min="); - })) << "a non-macOS target must never see a macOS deployment flag, even " + }))) << "a non-macOS target must never see a macOS deployment flag, even " "when built on a macOS host (the mirror defect #685 also fixes)"; -} +}}; // ── graph_runtime_compile_flags: what a `throw` and a `thread_local` compile // into, when the runtime comes from the dependency graph. @@ -401,11 +396,11 @@ bool has(const std::vector& v, std::string_view f) { // runtime supplies its own unwinder with it, and the two cannot be mixed inside // one image. A `thread_local` on PE is reached through `_tls_index`, which the // dynamic loader bootstraps and a self-contained image has no loader for. -TEST(GraphRuntimeFlags, PeTakesDwarfExceptionsAndEmulatedTls) { +UTest pe_takes_dwarf_exceptions_and_emulated_tls{"GraphRuntimeFlags/PeTakesDwarfExceptionsAndEmulatedTls", [] { auto f = mcpp::toolchain::graph_runtime_compile_flags(graph_tc("x86_64-windows-gnu")); - EXPECT_TRUE(has(f, "-fdwarf-exceptions")); - EXPECT_TRUE(has(f, "-femulated-tls")); -} + expect(bool(has(f, "-fdwarf-exceptions"))); + expect(bool(has(f, "-femulated-tls"))); +}}; // Mach-O: the exception mechanism is already DWARF, so only the thread-local // one applies — `_tlv_bootstrap` is the loader-bootstrapped name there. The @@ -413,73 +408,73 @@ TEST(GraphRuntimeFlags, PeTakesDwarfExceptionsAndEmulatedTls) { // definition is coalesced BY THE LOADER, which is machinery a self-contained // image has no use for and which produced a jump to address zero when it was // left in place. -TEST(GraphRuntimeFlags, MachOTakesEmulatedTlsAndHiddenVisibilityButNotDwarf) { +UTest mach_o_takes_emulated_tls_and_hidden_visibility_but_not_dwarf{"GraphRuntimeFlags/MachOTakesEmulatedTlsAndHiddenVisibilityButNotDwarf", [] { auto f = mcpp::toolchain::graph_runtime_compile_flags(graph_tc("aarch64-macos")); - EXPECT_FALSE(has(f, "-fdwarf-exceptions")); - EXPECT_TRUE(has(f, "-femulated-tls")); - EXPECT_TRUE(has(f, "-fvisibility=hidden")); - EXPECT_TRUE(has(f, "-fvisibility-inlines-hidden")); -} + expect(!has(f, "-fdwarf-exceptions")); + expect(bool(has(f, "-femulated-tls"))); + expect(bool(has(f, "-fvisibility=hidden"))); + expect(bool(has(f, "-fvisibility-inlines-hidden"))); +}}; // iOS shares macOS's object format (Mach-O, ld64), so it must share this // exact set of flags. Before `is_mach_o()` replaced `os == "macos"` here, an // iOS triple matched neither the PE nor the macOS branch and this function // silently returned no flags at all for it. -TEST(GraphRuntimeFlags, IosTakesTheSameFlagsAsMacOS) { +UTest ios_takes_the_same_flags_as_mac_os{"GraphRuntimeFlags/IosTakesTheSameFlagsAsMacOS", [] { auto f = mcpp::toolchain::graph_runtime_compile_flags(graph_tc("aarch64-ios")); - EXPECT_FALSE(has(f, "-fdwarf-exceptions")); - EXPECT_TRUE(has(f, "-femulated-tls")); - EXPECT_TRUE(has(f, "-fvisibility=hidden")); - EXPECT_TRUE(has(f, "-fvisibility-inlines-hidden")); -} + expect(!has(f, "-fdwarf-exceptions")); + expect(bool(has(f, "-femulated-tls"))); + expect(bool(has(f, "-fvisibility=hidden"))); + expect(bool(has(f, "-fvisibility-inlines-hidden"))); +}}; // A hosted Mach-O target whose C library is a located SDK (the iOS rows over // `llvm.libcxx`, mcpp#630) has dyld and the native TLS model with it, so the // emulated-TLS row does not apply; the visibility rows still do, since a // second libc++ in one process is kept apart by visibility on Mach-O. -TEST(GraphRuntimeFlags, MachOOverAPrebuiltCLibraryKeepsNativeTls) { +UTest mach_o_over_a_prebuilt_c_library_keeps_native_tls{"GraphRuntimeFlags/MachOOverAPrebuiltCLibraryKeepsNativeTls", [] { auto tc = graph_tc("aarch64-ios"); tc.cAbiPrebuilt = true; auto f = mcpp::toolchain::graph_runtime_compile_flags(tc); - EXPECT_FALSE(has(f, "-femulated-tls")); - EXPECT_FALSE(has(f, "-fdwarf-exceptions")); - EXPECT_TRUE(has(f, "-fvisibility=hidden")); - EXPECT_TRUE(has(f, "-fvisibility-inlines-hidden")); + expect(!has(f, "-femulated-tls")); + expect(!has(f, "-fdwarf-exceptions")); + expect(bool(has(f, "-fvisibility=hidden"))); + expect(bool(has(f, "-fvisibility-inlines-hidden"))); // And PE keeps it regardless: `_tls_index` is loader-bootstrapped there // whether or not the C library is the graph's. auto pe = graph_tc("x86_64-windows-gnu"); pe.cAbiPrebuilt = true; - EXPECT_TRUE(has(mcpp::toolchain::graph_runtime_compile_flags(pe), "-femulated-tls")); -} + expect(bool(has(mcpp::toolchain::graph_runtime_compile_flags(pe), "-femulated-tls"))); +}}; // ELF takes NONE of them, and that is a decision rather than an omission. // There a `thread_local` is a fixed offset from the thread pointer, which the // C library establishes itself; adding the flag would work, cost an // indirection on every access, and make ELF the only target whose thread // locals are laid out differently from every other build of the same target. -TEST(GraphRuntimeFlags, ElfTakesNone) { +UTest elf_takes_none{"GraphRuntimeFlags/ElfTakesNone", [] { auto f = mcpp::toolchain::graph_runtime_compile_flags(graph_tc("x86_64-linux-gnu")); - EXPECT_TRUE(f.empty()); -} + expect(f.empty()); +}}; // And nothing at all when the runtime is NOT the graph's, whatever the // target. The predicate is `targetCxxRuntime`; a native or payload-served build // of the same triple must be untouched. -TEST(GraphRuntimeFlags, PayloadServedTargetTakesNoneEvenOnPe) { +UTest payload_served_target_takes_none_even_on_pe{"GraphRuntimeFlags/PayloadServedTargetTakesNoneEvenOnPe", [] { mcpp::toolchain::Toolchain tc; tc.compiler = CompilerId::Clang; tc.targetTriple = "x86_64-windows-gnu"; tc.targetCxxRuntime = false; - EXPECT_TRUE(mcpp::toolchain::graph_runtime_compile_flags(tc).empty()); -} + expect(mcpp::toolchain::graph_runtime_compile_flags(tc).empty()); +}}; // A triple outside the vocabulary yields nothing rather than a guess: the // answer depends on the object format, and a spelling that cannot be parsed // does not name one. -TEST(GraphRuntimeFlags, UnparseableTripleTakesNone) { - EXPECT_TRUE(mcpp::toolchain::graph_runtime_compile_flags( +UTest unparseable_triple_takes_none{"GraphRuntimeFlags/UnparseableTripleTakesNone", [] { + expect(mcpp::toolchain::graph_runtime_compile_flags( graph_tc("not-a-triple-at-all")).empty()); -} +}}; // `--no-default-config` IS NOT PART OF THE PAYLOAD'S HEADER SET, AND WAS // BEING SUPPRESSED WITH IT. @@ -494,7 +489,7 @@ TEST(GraphRuntimeFlags, UnparseableTripleTakesNone) { // token, so clang read `bin/clang++.cfg`. That is also what made a hand-written // `-clang++.cfg` a working workaround for mcpp#514 — a workaround that // only existed because this token went missing. -TEST(HostFlags, TheCfgBypassSurvivesAGraphSuppliedTargetSide) { +UTest the_cfg_bypass_survives_a_graph_supplied_target_side{"HostFlags/TheCfgBypassSurvivesAGraphSuppliedTargetSide", [] { // A FIXTURE, NOT THE MACHINE'S OWN TOOLCHAIN. The first draft used a // synthetic `Toolchain` with no `binaryPath`, so `resolve_clang_driver` // reported no cfg and the whole test SKIPPED — a check that asserts @@ -520,7 +515,7 @@ TEST(HostFlags, TheCfgBypassSurvivesAGraphSuppliedTargetSide) { auto tc = tc_for(CompilerId::Clang); tc.binaryPath = root / "bin" / "clang++"; - ASSERT_TRUE(mcpp::toolchain::resolve_clang_driver(tc).hasCfg) + expect(fatal(bool(mcpp::toolchain::resolve_clang_driver(tc).hasCfg))) << "the fixture did not produce a cfg — the assertions below would be vacuous"; const auto has = [](const std::vector& v, std::string_view f) { @@ -542,13 +537,13 @@ TEST(HostFlags, TheCfgBypassSurvivesAGraphSuppliedTargetSide) { // The bypass is emitted on BOTH sides: the cfg is a per-machine, // per-install-path artifact, and reading it makes the command line depend // on what happened to be installed when the payload landed. - EXPECT_TRUE(has(a, "--no-default-config")); - EXPECT_TRUE(has(b, "--no-default-config")); + expect(bool(has(a, "--no-default-config"))); + expect(bool(has(b, "--no-default-config"))); // ...while the payload's own C++ headers stay withheld from the graph side, // which is the distinction this splits apart. - EXPECT_TRUE(has(a, "-nostdinc++")); - EXPECT_FALSE(has(b, "-nostdinc++")); -} + expect(bool(has(a, "-nostdinc++"))); + expect(!has(b, "-nostdinc++")); +}}; // "A TOOLCHAIN THAT SHIPS ITS OWN SYSROOT IS TOLD NOTHING" WAS ONE TOKEN TOO // STRONG, AND THIS FUNCTION ALREADY SAID SO FURTHER DOWN. @@ -573,7 +568,7 @@ TEST(HostFlags, TheCfgBypassSurvivesAGraphSuppliedTargetSide) { // // followed by eight cascading "use of undeclared identifier 'std'" lines, // which is what a reader sees first. -TEST(HostFlags, AnOwnSysrootTargetIsToldWhichTargetAndNothingElse) { +UTest an_own_sysroot_target_is_told_which_target_and_nothing_else{"HostFlags/AnOwnSysrootTargetIsToldWhichTargetAndNothingElse", [] { HostFlagOptions opt; for (auto name : {"aarch64-linux-android", "x86_64-linux-android", @@ -589,12 +584,12 @@ TEST(HostFlags, AnOwnSysrootTargetIsToldWhichTargetAndNothingElse) { // "contains", because the property is that nothing ELSE is emitted: // this host's glibc headers reaching a wasm compile is the measured // failure this gate exists for. - ASSERT_EQ(tokens.size(), 1u) << name << ": " << [&] { + expect(fatal(eq(tokens.size(), 1u))) << name << ": " << [&] { std::string all; for (auto const& t : tokens) { all += t; all += ' '; } return all; }(); - EXPECT_EQ(tokens[0], "--target=SENTINEL-TRIPLE") << name; + expect(eq(tokens[0], "--target=SENTINEL-TRIPLE")) << name; } // AND THE GATE IS STILL A GATE, DISCRIMINATED BY THE cfg BYPASS. @@ -633,8 +628,7 @@ TEST(HostFlags, AnOwnSysrootTargetIsToldWhichTargetAndNothingElse) { host.crossTargetFlag = "--target=x86_64-unknown-linux-gnu"; auto hostTokens = mcpp::toolchain::host_compile_tokens( host, bypass, mcpp::toolchain::no_escape); - EXPECT_NE(std::ranges::find(hostTokens, "--no-default-config"), - hostTokens.end()) + expect(ne(std::ranges::find(hostTokens, "--no-default-config"), hostTokens.end())) << "a hosted clang with a cfg beside it must reach the bypass"; for (auto name : {"aarch64-linux-android", "wasm32-emscripten"}) { @@ -644,21 +638,20 @@ TEST(HostFlags, AnOwnSysrootTargetIsToldWhichTargetAndNothingElse) { sdk.crossTargetFlag = "--target=SENTINEL-TRIPLE"; auto sdkTokens = mcpp::toolchain::host_compile_tokens( sdk, bypass, mcpp::toolchain::no_escape); - EXPECT_EQ(std::ranges::find(sdkTokens, "--no-default-config"), - sdkTokens.end()) + expect(eq(std::ranges::find(sdkTokens, "--no-default-config"), sdkTokens.end())) << name << ": the cfg bypass must be withheld from an SDK whose " "driver's job is to supply configuration"; - EXPECT_EQ(sdkTokens.size(), 1u) << name; + expect(eq(sdkTokens.size(), 1u)) << name; } // A row with no cross flag emits nothing at all rather than an empty // token: an empty argv element is an argument the driver must interpret. auto bare = tc_for(CompilerId::Clang); bare.targetTriple = "wasm32-emscripten"; - ASSERT_TRUE(bare.crossTargetFlag.empty()); - EXPECT_TRUE(mcpp::toolchain::host_compile_tokens( + expect(fatal(bare.crossTargetFlag.empty())); + expect(mcpp::toolchain::host_compile_tokens( bare, opt, mcpp::toolchain::no_escape).empty()); -} +}}; // ── The C++ headers are the C++ layer's question ───────────────────────────── // @@ -669,7 +662,7 @@ TEST(HostFlags, AnOwnSysrootTargetIsToldWhichTargetAndNothingElse) { // `llvm.libcxx`, mcpp#630) answered "payload" there and put two libc++ header // sets on one command line. The same fixture as the bypass test above: two // empty files beside an `include/c++/v1` are a complete driver model. -TEST(HostFlags, TheCxxLayerDecidesWhoseLibcxxHeadersAreEmitted) { +UTest the_cxx_layer_decides_whose_libcxx_headers_are_emitted{"HostFlags/TheCxxLayerDecidesWhoseLibcxxHeadersAreEmitted", [] { namespace fs = std::filesystem; const auto root = fs::temp_directory_path() / "mcpp_hostflags_cxx_fixture"; fs::remove_all(root); @@ -684,7 +677,7 @@ TEST(HostFlags, TheCxxLayerDecidesWhoseLibcxxHeadersAreEmitted) { auto tc = tc_for(CompilerId::Clang); tc.binaryPath = root / "bin" / "clang++"; - ASSERT_TRUE(mcpp::toolchain::resolve_clang_driver(tc).hasCfg) + expect(fatal(bool(mcpp::toolchain::resolve_clang_driver(tc).hasCfg))) << "the fixture did not produce a cfg — the assertions below would be vacuous"; const auto any_payload_cxx = [&](const std::vector& v) { @@ -704,9 +697,9 @@ TEST(HostFlags, TheCxxLayerDecidesWhoseLibcxxHeadersAreEmitted) { // The baseline: a prebuilt C library under the payload's own libc++ takes // the payload's headers, as every native build does. const auto a = mcpp::toolchain::host_compile_tokens(tc, payload, mcpp::toolchain::no_escape); - EXPECT_TRUE(any_payload_cxx(a)); - EXPECT_TRUE(has(a, "-nostdinc++")); - EXPECT_FALSE(has(a, "--rtlib=compiler-rt")); + expect(bool(any_payload_cxx(a))); + expect(bool(has(a, "-nostdinc++"))); + expect(!has(a, "--rtlib=compiler-rt")); // Runtime selection affects AArch64 PCM compatibility even on a compile // without a link. The host std producer and one-shot helper must agree. @@ -714,8 +707,8 @@ TEST(HostFlags, TheCxxLayerDecidesWhoseLibcxxHeadersAreEmitted) { arm.targetTriple = "aarch64-linux-gnu"; auto armCompile = mcpp::toolchain::host_compile_tokens(arm, payload, mcpp::toolchain::no_escape); auto armLink = mcpp::toolchain::host_link_tokens(arm, payload, mcpp::toolchain::no_escape); - EXPECT_TRUE(has(armCompile, "--rtlib=compiler-rt")); - EXPECT_TRUE(has(armLink, "--rtlib=compiler-rt")); + expect(bool(has(armCompile, "--rtlib=compiler-rt"))); + expect(bool(has(armLink, "--rtlib=compiler-rt"))); // A graph C++ runtime over the same prebuilt C library: the payload's // headers are withheld and the driver's own search is closed, since the @@ -723,13 +716,13 @@ TEST(HostFlags, TheCxxLayerDecidesWhoseLibcxxHeadersAreEmitted) { HostFlagOptions graph = payload; graph.cxxFromGraph = true; const auto b = mcpp::toolchain::host_compile_tokens(tc, graph, mcpp::toolchain::no_escape); - EXPECT_FALSE(any_payload_cxx(b)); - EXPECT_TRUE(has(b, "-nostdinc++")); - EXPECT_TRUE(has(b, "--no-default-config")); + expect(!any_payload_cxx(b)); + expect(bool(has(b, "-nostdinc++"))); + expect(bool(has(b, "--no-default-config"))); // A graph owns its runtime/codegen broadcast; payload selection must not // be introduced merely because the target architecture is AArch64. - EXPECT_FALSE(has(mcpp::toolchain::host_compile_tokens(arm, graph, mcpp::toolchain::no_escape), - "--rtlib=compiler-rt")); + expect(!(has(mcpp::toolchain::host_compile_tokens(arm, graph, mcpp::toolchain::no_escape), + "--rtlib=compiler-rt"))); // An Apple cross target whose runtime is the SDK's libc++ and whose graph // does not import `std`: prepare chose the SDK's headers, named @@ -739,9 +732,9 @@ TEST(HostFlags, TheCxxLayerDecidesWhoseLibcxxHeadersAreEmitted) { sdk.appleSdkRoot = fs::path("/Sdk/iPhoneSimulator.sdk"); sdk.appleSdkCxxHeaders = true; const auto c = mcpp::toolchain::host_compile_tokens(tc, sdk, mcpp::toolchain::no_escape); - EXPECT_FALSE(any_payload_cxx(c)); - EXPECT_TRUE(has(c, "-nostdinc++")); - EXPECT_TRUE(has(c, "-isystem" + (fs::path("/Sdk/iPhoneSimulator.sdk") / "usr" / "include" / "c++" / "v1").string())); + expect(!any_payload_cxx(c)); + expect(bool(has(c, "-nostdinc++"))); + expect(bool(has(c, "-isystem" + (fs::path("/Sdk/iPhoneSimulator.sdk") / "usr" / "include" / "c++" / "v1").string()))); // And with the graph runtime on that same target the SDK's headers are // not named either: one libc++ per command line, whichever it is. @@ -749,18 +742,18 @@ TEST(HostFlags, TheCxxLayerDecidesWhoseLibcxxHeadersAreEmitted) { sdkGraph.cxxFromGraph = true; sdkGraph.appleSdkCxxHeaders = false; const auto d = mcpp::toolchain::host_compile_tokens(tc, sdkGraph, mcpp::toolchain::no_escape); - EXPECT_FALSE(any_payload_cxx(d)); - EXPECT_FALSE(std::ranges::any_of(d, [](const std::string& t) { + expect(!any_payload_cxx(d)); + expect(!(std::ranges::any_of(d, [](const std::string& t) { return t.starts_with("-isystem") && t.find("iPhoneSimulator.sdk") != std::string::npos; - })); - EXPECT_TRUE(has(d, "-nostdinc++")); -} + }))); + expect(bool(has(d, "-nostdinc++"))); +}}; // The floating macros an Apple SDK leaves to once modules are on // (apple_float_macro_words). Stated for clang on an Apple target and for // nothing else, as the SDK's own spellings, and quotable by every reader. -TEST(HostFlags, AppleFloatMacrosAreStatedForClangOnAppleTargetsOnly) { +UTest apple_float_macros_are_stated_for_clang_on_apple_targets_only{"HostFlags/AppleFloatMacrosAreStatedForClangOnAppleTargetsOnly", [] { const std::vector words{ "-DINFINITY=HUGE_VALF", "-DNAN=__builtin_nanf(\"0x7fc00000\")"}; auto tc = [](CompilerId id, std::string triple) { @@ -769,14 +762,14 @@ TEST(HostFlags, AppleFloatMacrosAreStatedForClangOnAppleTargetsOnly) { t.targetTriple = std::move(triple); return t; }; - EXPECT_EQ(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::Clang, "arm64-apple-darwin27.0.0")), words); - EXPECT_EQ(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::Clang, "aarch64-macos")), words); - EXPECT_EQ(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::Clang, "arm64-apple-ios18.0")), words); - EXPECT_TRUE(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::Clang, "x86_64-linux-gnu")).empty()); - EXPECT_TRUE(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::Clang, "wasm32-emscripten")).empty()); - EXPECT_TRUE(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::GCC, "x86_64-linux-gnu")).empty()); - EXPECT_TRUE(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::MSVC, "x86_64-pc-windows-msvc")).empty()); -} + expect(eq(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::Clang, "arm64-apple-darwin27.0.0")), words)); + expect(eq(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::Clang, "aarch64-macos")), words)); + expect(eq(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::Clang, "arm64-apple-ios18.0")), words)); + expect(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::Clang, "x86_64-linux-gnu")).empty()); + expect(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::Clang, "wasm32-emscripten")).empty()); + expect(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::GCC, "x86_64-linux-gnu")).empty()); + expect(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::MSVC, "x86_64-pc-windows-msvc")).empty()); +}}; // ── #662: the C library's own host locations, and its C++ twin ───────────── // @@ -822,22 +815,22 @@ std::size_t count_of(const std::vector& v, std::string_view f) { // #511. Measured absent before this fix: the openkal windows-gnu row's // C units kept finding `/usr/x86_64-w64-mingw32/include` (the host's mingw) // ahead of musl's own headers. -TEST(HostFlags, GraphSuppliedCLibraryGetsNostdlibinc) { +UTest graph_supplied_c_library_gets_nostdlibinc{"HostFlags/GraphSuppliedCLibraryGetsNostdlibinc", [] { ClangCfgFixture fx("mcpp_hostflags_662_clib_fixture"); auto tc = fx.toolchain(); - ASSERT_TRUE(mcpp::toolchain::resolve_clang_driver(tc).hasCfg); + expect(fatal(bool(mcpp::toolchain::resolve_clang_driver(tc).hasCfg))); HostFlagOptions payload; payload.cfgBypass = HostFlagOptions::CfgBypass::Always; payload.cAbiPrebuilt = true; - EXPECT_FALSE(contains(mcpp::toolchain::host_compile_tokens( - tc, payload, mcpp::toolchain::no_escape), "-nostdlibinc")); + expect(!(contains(mcpp::toolchain::host_compile_tokens( + tc, payload, mcpp::toolchain::no_escape), "-nostdlibinc"))); HostFlagOptions graph = payload; graph.cAbiPrebuilt = false; - EXPECT_TRUE(contains(mcpp::toolchain::host_compile_tokens( + expect(contains(mcpp::toolchain::host_compile_tokens( tc, graph, mcpp::toolchain::no_escape), "-nostdlibinc")); -} +}}; // THE OPENKAL SHAPE ITSELF (#662): both layers come from the graph at once. // Before this fix `-nostdinc++` was conditioned on `!graphSuppliesTarget`, @@ -845,10 +838,10 @@ TEST(HostFlags, GraphSuppliedCLibraryGetsNostdlibinc) { // beside itself for the payload's libc++, found the HOST's libstdc++ // instead, and any unit reaching it transitively (not just `import std`) // carried two C++ standard libraries. -TEST(HostFlags, BothLayersFromGraphGetBothIsolationTokens) { +UTest both_layers_from_graph_get_both_isolation_tokens{"HostFlags/BothLayersFromGraphGetBothIsolationTokens", [] { ClangCfgFixture fx("mcpp_hostflags_662_both_fixture"); auto tc = fx.toolchain(); - ASSERT_TRUE(mcpp::toolchain::resolve_clang_driver(tc).hasCfg); + expect(fatal(bool(mcpp::toolchain::resolve_clang_driver(tc).hasCfg))); HostFlagOptions opt; opt.cfgBypass = HostFlagOptions::CfgBypass::Always; @@ -857,23 +850,23 @@ TEST(HostFlags, BothLayersFromGraphGetBothIsolationTokens) { const auto toks = mcpp::toolchain::host_compile_tokens( tc, opt, mcpp::toolchain::no_escape); - EXPECT_TRUE(contains(toks, "-nostdlibinc")); - EXPECT_TRUE(contains(toks, "-nostdinc++")); + expect(contains(toks, "-nostdlibinc")); + expect(contains(toks, "-nostdinc++")); // Exactly once each — the two branches that can add `-nostdinc++` // (graph C++, SDK C++) must not both fire for the same build. - EXPECT_EQ(count_of(toks, "-nostdinc++"), 1u); - EXPECT_EQ(count_of(toks, "-nostdlibinc"), 1u); -} + expect(eq(count_of(toks, "-nostdinc++"), 1u)); + expect(eq(count_of(toks, "-nostdlibinc"), 1u)); +}}; // THE REGRESSION GUARD: a build where both layers stay the payload's — every // native build, and every build over a prebuilt/payload C library — emits // the identical token sequence this fix touched nothing for. Compared as a // whole vector rather than by presence, so an accidental REORDERING (which // would still change the rendered command line) fails this test too. -TEST(HostFlags, PayloadServedCommandLineIsUnchanged) { +UTest payload_served_command_line_is_unchanged{"HostFlags/PayloadServedCommandLineIsUnchanged", [] { ClangCfgFixture fx("mcpp_hostflags_662_guard_fixture"); auto tc = fx.toolchain(); - ASSERT_TRUE(mcpp::toolchain::resolve_clang_driver(tc).hasCfg); + expect(fatal(bool(mcpp::toolchain::resolve_clang_driver(tc).hasCfg))); HostFlagOptions opt; opt.cfgBypass = HostFlagOptions::CfgBypass::Always; @@ -886,9 +879,9 @@ TEST(HostFlags, PayloadServedCommandLineIsUnchanged) { "--no-default-config", "-nostdinc++", "-isystem" + (fx.root / "include" / "c++" / "v1").string(), }; - EXPECT_EQ(toks, expected); - EXPECT_FALSE(contains(toks, "-nostdlibinc")); -} + expect(eq(toks, expected)); + expect(!contains(toks, "-nostdlibinc")); +}}; // GCC's fixture carries no `.cfg` (resolve_clang_driver only looks // for one beside a Clang binary), so `bypassCfg` is false and neither @@ -899,7 +892,7 @@ TEST(HostFlags, PayloadServedCommandLineIsUnchanged) { // contributes beside the sysroot) — so GCC stays exactly as it was before // #662: unisolated. No target row in this codebase's own target table pairs // a graph-supplied C library with GCC today, so nothing currently needs it. -TEST(HostFlags, GccEmitsNeitherIsolationTokenRegardlessOfGraphOrigin) { +UTest gcc_emits_neither_isolation_token_regardless_of_graph_origin{"HostFlags/GccEmitsNeitherIsolationTokenRegardlessOfGraphOrigin", [] { auto tc = tc_for(CompilerId::GCC); HostFlagOptions opt; @@ -908,16 +901,16 @@ TEST(HostFlags, GccEmitsNeitherIsolationTokenRegardlessOfGraphOrigin) { opt.cxxFromGraph = true; const auto toks = mcpp::toolchain::host_compile_tokens( tc, opt, mcpp::toolchain::no_escape); - EXPECT_FALSE(contains(toks, "-nostdlibinc")); - EXPECT_FALSE(contains(toks, "-nostdinc++")); -} + expect(!contains(toks, "-nostdlibinc")); + expect(!contains(toks, "-nostdinc++")); +}}; // ─── Clang on the MSVC ABI: one toolset for the compile, std module and link // // `host_compile_tokens` is read by the compile line, the std module precompile // and the build.mcpp host compile, so the toolset words appearing here is what // makes all three compile against the toolset prepare chose. -TEST(HostFlags, ClangOnTheMsvcAbiCompilesAndLinksAgainstTheChosenToolset) { +UTest clang_on_the_msvc_abi_compiles_and_links_against_the_chosen_toolset{"HostFlags/ClangOnTheMsvcAbiCompilesAndLinksAgainstTheChosenToolset", [] { mcpp::toolchain::Toolchain t; t.compiler = CompilerId::Clang; t.targetTriple = "x86_64-pc-windows-msvc"; @@ -934,15 +927,15 @@ TEST(HostFlags, ClangOnTheMsvcAbiCompilesAndLinksAgainstTheChosenToolset) { auto compile = mcpp::toolchain::host_compile_tokens(t, opt, mcpp::toolchain::no_escape); auto link = mcpp::toolchain::host_link_tokens(t, opt, mcpp::toolchain::no_escape); for (auto const* v : {&compile, &link}) { - EXPECT_TRUE(find_pair(*v, "-Xmicrosoft-visualc-tools-root", - "C:/VS/VC/Tools/MSVC/14.44.35207")); - EXPECT_TRUE(find_pair(*v, "-Xmicrosoft-windows-sdk-root", "C:/Kits/10")); - EXPECT_TRUE(find_pair(*v, "-Xmicrosoft-windows-sdk-version", "10.0.26100.0")); + expect(bool(find_pair(*v, "-Xmicrosoft-visualc-tools-root", + "C:/VS/VC/Tools/MSVC/14.44.35207"))); + expect(bool(find_pair(*v, "-Xmicrosoft-windows-sdk-root", "C:/Kits/10"))); + expect(bool(find_pair(*v, "-Xmicrosoft-windows-sdk-version", "10.0.26100.0"))); } // Without a recorded toolset nothing is said, which is the behaviour of // every build before a toolset is bound. t.base.msvc.toolsDir.clear(); for (auto const& w : mcpp::toolchain::host_compile_tokens(t, opt, mcpp::toolchain::no_escape)) - EXPECT_EQ(w.find("-Xmicrosoft"), std::string::npos) << w; -} + expect(eq(w.find("-Xmicrosoft"), std::string::npos)) << w; +}}; diff --git a/tests/unit/test_index_contract.cpp b/tests/unit/test_index_contract.cpp index 09cbe77f9..34f58b051 100644 --- a/tests/unit/test_index_contract.cpp +++ b/tests/unit/test_index_contract.cpp @@ -1,75 +1,77 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pm.index_contract; -TEST(IndexContract, FloorViolationOrdering) { +using namespace mcpplibs::mtest; + +UTest floor_violation_ordering{"IndexContract/FloorViolationOrdering", [] { using mcpp::pm::floor_violation; - EXPECT_FALSE(floor_violation("0.0.85", "0.0.85").has_value()); - EXPECT_FALSE(floor_violation("0.0.85", "0.0.90").has_value()); - EXPECT_FALSE(floor_violation("0.0.85", "1.0.0").has_value()); + expect(!(floor_violation("0.0.85", "0.0.85").has_value())); + expect(!(floor_violation("0.0.85", "0.0.90").has_value())); + expect(!(floor_violation("0.0.85", "1.0.0").has_value())); auto v = floor_violation("0.0.85", "0.0.84"); - ASSERT_TRUE(v.has_value()); - EXPECT_NE(v->find("E0006"), std::string::npos); - EXPECT_NE(v->find("0.0.85"), std::string::npos); -} + expect(fatal(v.has_value())); + expect(ne(v->find("E0006"), std::string::npos)); + expect(ne(v->find("0.0.85"), std::string::npos)); +}}; // The Upgrade advice must tell the user which install method THEY have. A // single hardcoded install.sh one-liner misleads distro (AUR) users into // installing a second copy that does not update the running binary. -TEST(IndexContract, E0006MessageSwitchesUpgradeAdviceByLayout) { +UTest e0006_message_switches_upgrade_advice_by_layout{"IndexContract/E0006MessageSwitchesUpgradeAdviceByLayout", [] { using mcpp::pm::floor_violation; using mcpp::pm::e0006_message; auto violation = floor_violation("2026.8.3.3", "2026.7.28.2"); - ASSERT_TRUE(violation.has_value()); + expect(fatal(violation.has_value())); // Non-distro (install.sh / xlings) layout keeps the install.sh one-liner // and always appends the recommended-installer note. auto script = e0006_message(*violation, /*distroManaged=*/false); - EXPECT_NE(script.find("install.sh"), std::string::npos); - EXPECT_EQ(script.find("distro-managed"), std::string::npos); - EXPECT_NE(script.find("xlings update mcpp"), std::string::npos); - EXPECT_NE(script.find("E0006"), std::string::npos); + expect(ne(script.find("install.sh"), std::string::npos)); + expect(eq(script.find("distro-managed"), std::string::npos)); + expect(ne(script.find("xlings update mcpp"), std::string::npos)); + expect(ne(script.find("E0006"), std::string::npos)); // Distro (AUR) layout swaps in the package-manager advice. auto distro = e0006_message(*violation, /*distroManaged=*/true); - EXPECT_NE(distro.find("distro-managed"), std::string::npos); - EXPECT_EQ(distro.find("re-run the install.sh one-liner"), std::string::npos); - EXPECT_NE(distro.find("xlings update mcpp"), std::string::npos); - EXPECT_NE(distro.find("E0006"), std::string::npos); -} + expect(ne(distro.find("distro-managed"), std::string::npos)); + expect(eq(distro.find("re-run the install.sh one-liner"), std::string::npos)); + expect(ne(distro.find("xlings update mcpp"), std::string::npos)); + expect(ne(distro.find("E0006"), std::string::npos)); +}}; // The floor compares mcpp's OWN version, so the date scheme (YYYY.M.D.N) has // to be ordered on all four segments. While parse_version truncated at three, // every release of a given day compared equal and the floor let a too-old mcpp // straight through — the exact failure this check exists to prevent. -TEST(IndexContract, FloorViolationSeparatesSameDayReleases) { +UTest floor_violation_separates_same_day_releases{"IndexContract/FloorViolationSeparatesSameDayReleases", [] { using mcpp::pm::floor_violation; - EXPECT_FALSE(floor_violation("2026.7.27.1", "2026.7.27.1").has_value()); - EXPECT_FALSE(floor_violation("2026.7.27.1", "2026.7.27.2").has_value()); - EXPECT_FALSE(floor_violation("2026.7.27.9", "2026.7.27.10").has_value()); + expect(!(floor_violation("2026.7.27.1", "2026.7.27.1").has_value())); + expect(!(floor_violation("2026.7.27.1", "2026.7.27.2").has_value())); + expect(!(floor_violation("2026.7.27.9", "2026.7.27.10").has_value())); auto v = floor_violation("2026.7.27.5", "2026.7.27.1"); - ASSERT_TRUE(v.has_value()) << "a lower same-day ordinal must violate the floor"; - EXPECT_NE(v->find("E0006"), std::string::npos); - EXPECT_NE(v->find("2026.7.27.5"), std::string::npos); -} + expect(fatal(v.has_value())) << "a lower same-day ordinal must violate the floor"; + expect(ne(v->find("E0006"), std::string::npos)); + expect(ne(v->find("2026.7.27.5"), std::string::npos)); +}}; -TEST(IndexContract, FloorViolationAcrossVersionSchemes) { +UTest floor_violation_across_version_schemes{"IndexContract/FloorViolationAcrossVersionSchemes", [] { using mcpp::pm::floor_violation; // A date-scheme mcpp satisfies any legacy 0.0.x floor. - EXPECT_FALSE(floor_violation("0.0.109", "2026.7.27.1").has_value()); + expect(!(floor_violation("0.0.109", "2026.7.27.1").has_value())); // A legacy mcpp does not satisfy a date-scheme floor. - EXPECT_TRUE(floor_violation("2026.7.27.1", "0.0.109").has_value()); -} + expect(floor_violation("2026.7.27.1", "0.0.109").has_value()); +}}; -TEST(IndexContract, EmptyOrMalformedNeverBricks) { +UTest empty_or_malformed_never_bricks{"IndexContract/EmptyOrMalformedNeverBricks", [] { using mcpp::pm::floor_violation; - EXPECT_FALSE(floor_violation("", "0.0.84").has_value()); - EXPECT_FALSE(floor_violation("not-a-version", "0.0.84").has_value()); -} + expect(!(floor_violation("", "0.0.84").has_value())); + expect(!(floor_violation("not-a-version", "0.0.84").has_value())); +}}; -TEST(IndexContract, ReadContractRoundTrip) { +UTest read_contract_round_trip{"IndexContract/ReadContractRoundTrip", [] { auto dir = std::filesystem::temp_directory_path() / "mcpp_ic_test"; std::filesystem::create_directories(dir); { @@ -77,33 +79,33 @@ TEST(IndexContract, ReadContractRoundTrip) { os << "[index]\nspec = \"1\"\nmin_mcpp = \"0.0.85\"\nlatest_mcpp = \"0.0.86\"\n"; } auto c = mcpp::pm::read_index_contract(dir); - ASSERT_TRUE(c.has_value()); - EXPECT_EQ(c->spec, "1"); - EXPECT_EQ(c->minMcpp, "0.0.85"); - EXPECT_EQ(c->latestMcpp, "0.0.86"); + expect(fatal(c.has_value())); + expect(eq(c->spec, "1")); + expect(eq(c->minMcpp, "0.0.85")); + expect(eq(c->latestMcpp, "0.0.86")); std::filesystem::remove_all(dir); - EXPECT_FALSE(mcpp::pm::read_index_contract(dir).has_value()); -} + expect(!(mcpp::pm::read_index_contract(dir).has_value())); +}}; // The upgrade note appended to E0006 starts on its own line. It was appended // directly after the "Details:" line and read as one line. // The closing tip of a refresh names the version the index asks for, and // falls back to the advice alone when the contract could not be read. -TEST(IndexContract, FloorUpgradeAdviceNamesTheVersionWhenKnown) { +UTest floor_upgrade_advice_names_the_version_when_known{"IndexContract/FloorUpgradeAdviceNamesTheVersionWhenKnown", [] { auto known = mcpp::pm::index_floor_upgrade_advice("9999.9.9.9"); - EXPECT_NE(known.find("requires mcpp >= 9999.9.9.9"), std::string::npos) << known; - EXPECT_NE(known.find("Upgrade: "), std::string::npos) << known; + expect(ne(known.find("requires mcpp >= 9999.9.9.9"), std::string::npos)) << known; + expect(ne(known.find("Upgrade: "), std::string::npos)) << known; auto unknown = mcpp::pm::index_floor_upgrade_advice(""); - EXPECT_EQ(unknown.find(">="), std::string::npos) << unknown; - EXPECT_TRUE(unknown.starts_with("Upgrade: ")) << unknown; -} + expect(eq(unknown.find(">="), std::string::npos)) << unknown; + expect(unknown.starts_with("Upgrade: ")) << unknown; +}}; -TEST(IndexContract, E0006UpgradeNoteStartsOnItsOwnLine) { +UTest e0006_upgrade_note_starts_on_its_own_line{"IndexContract/E0006UpgradeNoteStartsOnItsOwnLine", [] { auto violation = mcpp::pm::floor_violation("2026.8.3.3", "2026.7.28.2"); - ASSERT_TRUE(violation.has_value()); + expect(fatal(violation.has_value())); auto text = mcpp::pm::e0006_message(*violation, /*distroManaged=*/false); auto at = text.find(" Upgrade: 'xlings update mcpp'"); - ASSERT_NE(at, std::string::npos) << text; - ASSERT_GT(at, 0u); - EXPECT_EQ(text[at - 1], '\n') << text; -} + expect(fatal(ne(at, std::string::npos))) << text; + expect(fatal(gt(at, 0u))); + expect(eq(text[at - 1], '\n')) << text; +}}; diff --git a/tests/unit/test_install_hook_env.cpp b/tests/unit/test_install_hook_env.cpp index bc8222ac7..14a98a3bf 100644 --- a/tests/unit/test_install_hook_env.cpp +++ b/tests/unit/test_install_hook_env.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.build_program; +using namespace mcpplibs::mtest; + // #613: the part of a build program's environment an install hook receives. // The names, their order, and the rule for each value are stated here once for // every platform; tests/e2e/648 shows a real install hook reading them. @@ -14,49 +16,49 @@ using HookEnv = std::vector>; std::string value_of(const HookEnv& env, std::string_view key) { for (auto const& [k, v] : env) if (k == key) return v; - ADD_FAILURE() << key << " is missing"; + expect(false) << key << " is missing"; return {}; } } // namespace -TEST(InstallHookEnv, NamesTheSixVariablesInOrder) { +UTest names_the_six_variables_in_order{"InstallHookEnv/NamesTheSixVariablesInOrder", [] { auto env = mcpp::build::install_hook_env(mcpp::build::BuildProgramEnv{}); std::vector names; for (auto const& [k, v] : env) names.push_back(k); - EXPECT_EQ(names, (std::vector{ + expect(eq(names, (std::vector{ "MCPP_COMPILER", "MCPP_CXX_STDLIB", "MCPP_TARGET", - "MCPP_TARGET_OS", "MCPP_TARGET_ARCH", "MCPP_TARGET_ENV"})); -} + "MCPP_TARGET_OS", "MCPP_TARGET_ARCH", "MCPP_TARGET_ENV"}))); +}}; -TEST(InstallHookEnv, WithoutAToolchainTheToolchainValuesArePresentAndEmpty) { +UTest without_a_toolchain_the_toolchain_values_are_present_and_empty{"InstallHookEnv/WithoutAToolchainTheToolchainValuesArePresentAndEmpty", [] { auto env = mcpp::build::install_hook_env(mcpp::build::BuildProgramEnv{}); - EXPECT_EQ(value_of(env, "MCPP_COMPILER"), ""); - EXPECT_EQ(value_of(env, "MCPP_CXX_STDLIB"), ""); -} + expect(eq(value_of(env, "MCPP_COMPILER"), "")); + expect(eq(value_of(env, "MCPP_CXX_STDLIB"), "")); +}}; -TEST(InstallHookEnv, ANativeBuildNamesTheHost) { +UTest a_native_build_names_the_host{"InstallHookEnv/ANativeBuildNamesTheHost", [] { mcpp::build::BuildProgramEnv bp; bp.compilerId = "gcc"; bp.cxxStdlib = "libstdc++"; auto env = mcpp::build::install_hook_env(bp); - EXPECT_EQ(value_of(env, "MCPP_COMPILER"), "gcc"); - EXPECT_EQ(value_of(env, "MCPP_CXX_STDLIB"), "libstdc++"); + expect(eq(value_of(env, "MCPP_COMPILER"), "gcc")); + expect(eq(value_of(env, "MCPP_CXX_STDLIB"), "libstdc++")); const auto target = value_of(env, "MCPP_TARGET"); - ASSERT_FALSE(target.empty()); - EXPECT_FALSE(value_of(env, "MCPP_TARGET_OS").empty()) << target; - EXPECT_TRUE(target.starts_with(value_of(env, "MCPP_TARGET_ARCH"))) << target; -} + expect(fatal(!target.empty())); + expect(!(value_of(env, "MCPP_TARGET_OS").empty())) << target; + expect(target.starts_with(value_of(env, "MCPP_TARGET_ARCH"))) << target; +}}; -TEST(InstallHookEnv, ACrossBuildSplitsTheRequestedTriple) { +UTest a_cross_build_splits_the_requested_triple{"InstallHookEnv/ACrossBuildSplitsTheRequestedTriple", [] { mcpp::build::BuildProgramEnv bp; bp.targetTriple = "aarch64-linux-android"; bp.compilerId = "clang"; bp.cxxStdlib = "libc++"; auto env = mcpp::build::install_hook_env(bp); - EXPECT_EQ(value_of(env, "MCPP_TARGET"), "aarch64-linux-android"); - EXPECT_EQ(value_of(env, "MCPP_TARGET_OS"), "linux"); - EXPECT_EQ(value_of(env, "MCPP_TARGET_ARCH"), "aarch64"); - EXPECT_EQ(value_of(env, "MCPP_TARGET_ENV"), "android"); - EXPECT_EQ(value_of(env, "MCPP_CXX_STDLIB"), "libc++"); -} + expect(eq(value_of(env, "MCPP_TARGET"), "aarch64-linux-android")); + expect(eq(value_of(env, "MCPP_TARGET_OS"), "linux")); + expect(eq(value_of(env, "MCPP_TARGET_ARCH"), "aarch64")); + expect(eq(value_of(env, "MCPP_TARGET_ENV"), "android")); + expect(eq(value_of(env, "MCPP_CXX_STDLIB"), "libc++")); +}}; diff --git a/tests/unit/test_key_registry.cpp b/tests/unit/test_key_registry.cpp index b096647b2..49e048b5c 100644 --- a/tests/unit/test_key_registry.cpp +++ b/tests/unit/test_key_registry.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.project; +using namespace mcpplibs::mtest; + // SPEC-004 §9.10: the key registry, the workspace layer and the merge rules // (W1-W3, W7). The end-to-end halves are tests/e2e/893-895 and 900. @@ -13,7 +15,7 @@ namespace { mm::Manifest parse(std::string_view text) { auto m = mm::parse_string(text); - if (!m) ADD_FAILURE() << m.error().format(); + if (!m) expect(false) << m.error().format(); return m ? *m : mm::Manifest{}; } @@ -29,18 +31,18 @@ std::string parse_error(std::string_view text) { // key fills. They must name the same keys; `linkage` is the one key // [workspace.build] reads that [build] does not (the target row and the // profile set it there). -TEST(KeyRegistry, TheSharedBuildKeysAreTheWorkspaceBuildKeys) { +UTest the_shared_build_keys_are_the_workspace_build_keys{"KeyRegistry/TheSharedBuildKeysAreTheWorkspaceBuildKeys", [] { std::set shared, table; for (auto const& row : mm::kKeyRegistry) if (row.table == "build" && row.shared) shared.emplace(row.key); for (auto const& row : mm::kWorkspaceBuildKeys) if (row.key != "linkage") table.emplace(row.key); - EXPECT_EQ(shared, table); -} + expect(eq(shared, table)); +}}; // Every configuration key of [build] is part of the value members are grouped // by: two members that differ in one are not one plan. -TEST(KeyRegistry, EveryConfigurationKeyEntersTheGroupingKey) { +UTest every_configuration_key_enters_the_grouping_key{"KeyRegistry/EveryConfigurationKeyEntersTheGroupingKey", [] { const auto key = mcpp::project::root_position_key(mm::Manifest{}); const std::map spelled = { {"profile", "default_profile"}, {"default-profile", "default_profile"}, @@ -50,12 +52,12 @@ TEST(KeyRegistry, EveryConfigurationKeyEntersTheGroupingKey) { if (row.table != "build" || row.scope != mm::KeyScope::Configuration) continue; auto it = spelled.find(row.key); const std::string name(it == spelled.end() ? row.key : it->second); - EXPECT_NE(key.find(name + "="), std::string::npos) << row.key; + expect(ne(key.find(name + "="), std::string::npos)) << row.key; } -} +}}; // The parser accepts every [build] key of the registry without a warning. -TEST(KeyRegistry, TheParserReadsTheRegistrysBuildKeys) { +UTest the_parser_reads_the_registrys_build_keys{"KeyRegistry/TheParserReadsTheRegistrysBuildKeys", [] { auto m = parse(R"( [package] name = "x" @@ -66,7 +68,7 @@ cxxflags = ["-O2"] dialect_cxxflags = ["-DX=1"] cache = "local" )"); - EXPECT_TRUE(m.schemaWarnings.empty()); + expect(m.schemaWarnings.empty()); auto w = parse(R"( [package] name = "x" @@ -74,12 +76,12 @@ version = "0.1.0" [build] cxxflagz = ["-O2"] )"); - ASSERT_EQ(w.schemaWarnings.size(), 1u); - EXPECT_NE(w.schemaWarnings.front().find("cxxflagz"), std::string::npos); -} + expect(fatal(eq(w.schemaWarnings.size(), 1u))); + expect(ne(w.schemaWarnings.front().find("cxxflagz"), std::string::npos)); +}}; // W2: [workspace.X] is read by the reader of X. -TEST(WorkspaceLayer, MirrorsAreReadByTheReaderOfTheTableTheyMirror) { +UTest mirrors_are_read_by_the_reader_of_the_table_they_mirror{"WorkspaceLayer/MirrorsAreReadByTheReaderOfTheTableTheyMirror", [] { auto ws = parse(R"( [workspace] members = ["a"] @@ -95,58 +97,57 @@ cxxflags = ["-DSHARED"] [workspace.xlings.workspace] cmake = "4.0.2" )"); - ASSERT_TRUE(ws.layer); + expect(fatal(bool(ws.layer))); auto const& layer = *ws.layer; - EXPECT_EQ(layer.toolchain.for_platform("linux").value_or(""), "llvm@23.1.3"); - ASSERT_TRUE(layer.profiles.contains("release")); - EXPECT_EQ(layer.profiles.at("release").optLevel, "3"); - EXPECT_TRUE(layer.profiles.at("release").optDeclared); - EXPECT_FALSE(layer.profiles.at("release").debugDeclared); - EXPECT_EQ(layer.targetOverrides.at("x86_64-linux-musl").linkage, "static"); - ASSERT_FALSE(layer.conditionalConfigs.empty()); - EXPECT_EQ(ws.workspace.layerTables, - (std::vector{"toolchain", "profile", "target", "xlings"})); + expect(eq(layer.toolchain.for_platform("linux").value_or(""), "llvm@23.1.3")); + expect(fatal(layer.profiles.contains("release"))); + expect(eq(layer.profiles.at("release").optLevel, "3")); + expect(bool(layer.profiles.at("release").optDeclared)); + expect(!(layer.profiles.at("release").debugDeclared)); + expect(eq(layer.targetOverrides.at("x86_64-linux-musl").linkage, "static")); + expect(fatal(!layer.conditionalConfigs.empty())); + expect(eq(ws.workspace.layerTables, (std::vector{"toolchain", "profile", "target", "xlings"}))); // The root itself states none of them for its own package. - EXPECT_TRUE(ws.toolchain.byPlatform.empty()); - EXPECT_TRUE(ws.targetOverrides.empty()); -} + expect(ws.toolchain.byPlatform.empty()); + expect(ws.targetOverrides.empty()); +}}; -TEST(WorkspaceLayer, RefusesWhatDescribesOnePackageAndWhatItDoesNotKnow) { - EXPECT_NE(parse_error(R"( +UTest refuses_what_describes_one_package_and_what_it_does_not_know{"WorkspaceLayer/RefusesWhatDescribesOnePackageAndWhatItDoesNotKnow", [] { + expect(ne(parse_error(R"( [workspace] members = ["a"] [workspace.tolchain] default = "gcc@16.1.0" -)").find("[workspace] has no key or table 'tolchain'"), std::string::npos); - EXPECT_NE(parse_error(R"( +)").find("[workspace] has no key or table 'tolchain'"), std::string::npos)); + expect(ne(parse_error(R"( [workspace] members = ["a"] [workspace.target.x86_64-linux-gnu.dependencies] fmt = "11.0.0" -)").find("describes one package"), std::string::npos); - EXPECT_NE(parse_error(R"( +)").find("describes one package"), std::string::npos)); + expect(ne(parse_error(R"( [workspace] members = ["a"] [workspace.target.x86_64-linux-gnu.build] sources = ["x.cpp"] -)").find("has no key 'sources'"), std::string::npos); - EXPECT_NE(parse_error(R"( +)").find("has no key 'sources'"), std::string::npos)); + expect(ne(parse_error(R"( [workspace] members = ["a"] [workspace.target.x86_64-linux-gnu.build] allow_host_libs = true -)").find("allow_host_libs is not inheritable"), std::string::npos); +)").find("allow_host_libs is not inheritable"), std::string::npos)); // On a root without [package], one table cannot speak to members twice. - EXPECT_NE(parse_error(R"( + expect(ne(parse_error(R"( [workspace] members = ["a"] [toolchain] default = "gcc@16.1.0" [workspace.toolchain] default = "llvm@23.1.3" -)").find("write it once, as [workspace.toolchain]"), std::string::npos); +)").find("write it once, as [workspace.toolchain]"), std::string::npos)); // With [package], the root-position table is the root package's own. - EXPECT_TRUE(parse_error(R"( + expect(parse_error(R"( [package] name = "root" version = "0.1.0" @@ -157,10 +158,10 @@ default = "gcc@16.1.0" [workspace.toolchain] default = "llvm@23.1.3" )").empty()); -} +}}; // W7: a package table on a root without [package] acts on nothing. -TEST(WorkspaceLayer, PackageTablesOnAVirtualRootAreRecorded) { +UTest package_tables_on_a_virtual_root_are_recorded{"WorkspaceLayer/PackageTablesOnAVirtualRootAreRecorded", [] { auto ws = parse(R"( [workspace] members = ["a"] @@ -169,9 +170,9 @@ cxxflags = ["-O2"] [target.'cfg(os = "linux")'.build] dialect_cxxflags = ["-DX=1"] )"); - ASSERT_EQ(ws.virtualRootPackageTables.size(), 2u); - EXPECT_NE(ws.virtualRootPackageTables[0].find("[build]"), std::string::npos); - EXPECT_NE(ws.virtualRootPackageTables[1].find("[workspace.target."), std::string::npos); + expect(fatal(eq(ws.virtualRootPackageTables.size(), 2u))); + expect(ne(ws.virtualRootPackageTables[0].find("[build]"), std::string::npos)); + expect(ne(ws.virtualRootPackageTables[1].find("[workspace.target."), std::string::npos)); auto rooted = parse(R"( [package] name = "root" @@ -181,12 +182,12 @@ members = ["a"] [build] cxxflags = ["-O2"] )"); - EXPECT_TRUE(rooted.virtualRootPackageTables.empty()); -} + expect(rooted.virtualRootPackageTables.empty()); +}}; // W3: scalars the member declared are the member's, named tables merge key by // key, lists put the workspace first, conditional rows come first. -TEST(WorkspaceLayer, TheMergeRuleIsTheKeys) { +UTest the_merge_rule_is_the_keys{"WorkspaceLayer/TheMergeRuleIsTheKeys", [] { auto ws = parse(R"( [workspace] members = ["a"] @@ -219,18 +220,17 @@ dialect_cxxflags = ["-DM=1"] )"); mcpp::project::inherit_workspace_root_position(member, ws, "/ws"); mcpp::project::inherit_workspace_layer_rows(member, ws, "/ws"); - EXPECT_EQ(member.toolchain.for_platform("linux").value_or(""), "gcc@16.1.0"); - EXPECT_EQ(member.toolchain.for_platform("macos").value_or(""), "llvm@23.1.3"); + expect(eq(member.toolchain.for_platform("linux").value_or(""), "gcc@16.1.0")); + expect(eq(member.toolchain.for_platform("macos").value_or(""), "llvm@23.1.3")); auto const& r = member.profiles.at("release"); - EXPECT_EQ(r.optLevel, "2"); - EXPECT_TRUE(r.lto); - EXPECT_EQ(r.cxxflags, (std::vector{"-DWS", "-DMEMBER"})); + expect(eq(r.optLevel, "2")); + expect(bool(r.lto)); + expect(eq(r.cxxflags, (std::vector{"-DWS", "-DMEMBER"}))); auto const& t = member.targetOverrides.at("x86_64-linux-musl"); - EXPECT_EQ(t.linkage, "dynamic"); - EXPECT_EQ(t.cxxRuntime, "self-contained"); - ASSERT_GE(member.conditionalConfigs.size(), 2u); - EXPECT_EQ(member.conditionalConfigs.front().dialectCxxflags, - (std::vector{"-DW=1"})); + expect(eq(t.linkage, "dynamic")); + expect(eq(t.cxxRuntime, "self-contained")); + expect(fatal(ge(member.conditionalConfigs.size(), 2u))); + expect(eq(member.conditionalConfigs.front().dialectCxxflags, (std::vector{"-DW=1"}))); // A member's own `default` speaks for every platform. auto own = parse(R"( @@ -241,11 +241,11 @@ version = "0.1.0" default = "gcc@16.1.0" )"); mcpp::project::inherit_workspace_root_position(own, ws, "/ws"); - EXPECT_EQ(own.toolchain.for_platform("macos").value_or(""), "gcc@16.1.0"); -} + expect(eq(own.toolchain.for_platform("macos").value_or(""), "gcc@16.1.0")); +}}; // W4: grouping counts a conditional configuration row as it evaluates. -TEST(WorkspaceLayer, GroupingReadsEvaluatedRows) { +UTest grouping_reads_evaluated_rows{"WorkspaceLayer/GroupingReadsEvaluatedRows", [] { auto a = parse(R"( [package] name = "a" @@ -260,6 +260,6 @@ version = "0.1.0" )"); auto linux = [](std::string_view p) { return p.find("linux") != std::string_view::npos; }; auto windows = [](std::string_view p) { return p.find("windows") != std::string_view::npos; }; - EXPECT_EQ(mcpp::project::root_position_key(a, linux), mcpp::project::root_position_key(b, linux)); - EXPECT_NE(mcpp::project::root_position_key(a, windows), mcpp::project::root_position_key(b, windows)); -} + expect(eq(mcpp::project::root_position_key(a, linux), mcpp::project::root_position_key(b, linux))); + expect(ne(mcpp::project::root_position_key(a, windows), mcpp::project::root_position_key(b, windows))); +}}; diff --git a/tests/unit/test_library_interface.cpp b/tests/unit/test_library_interface.cpp index 93b6ae562..71dcf8193 100644 --- a/tests/unit/test_library_interface.cpp +++ b/tests/unit/test_library_interface.cpp @@ -1,10 +1,12 @@ -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.modgraph.graph; import mcpp.modgraph.library_interface; +using namespace mcpplibs::mtest; + // ONE DEFINITION OF WHAT A LIBRARY OFFERS (SPEC-008 v0.2, mcpp#797): the // interface root and what it re-exports, the interface units of the active // features, and -- with no root -- every exported module. The target build, @@ -43,7 +45,7 @@ SourceUnit unit(const std::filesystem::path& p, std::string module, } // namespace -TEST(LibraryInterface, TheRootAndWhatItReExports) { +UTest the_root_and_what_it_re_exports{"LibraryInterface/TheRootAndWhatItReExports", [] { Tree t("root"); Graph g; g.units.push_back(unit(t.touch("src/lib.cppm"), "acme.lib", {"acme.lib.part"})); @@ -52,13 +54,12 @@ TEST(LibraryInterface, TheRootAndWhatItReExports) { mcpp::manifest::Manifest m; m.package.name = "lib"; auto iface = library_interface(g, m, t.root, "acme.lib", {}); - EXPECT_EQ(iface.rootModule, "acme.lib"); - EXPECT_EQ(iface.publicModules, (std::set{"acme.lib", "acme.lib.part"})); - EXPECT_EQ(iface.exportedModules, - (std::set{"acme.lib", "acme.lib.part", "acme.lib.detail"})); -} + expect(eq(iface.rootModule, "acme.lib")); + expect(eq(iface.publicModules, (std::set{"acme.lib", "acme.lib.part"}))); + expect(eq(iface.exportedModules, (std::set{"acme.lib", "acme.lib.part", "acme.lib.detail"}))); +}}; -TEST(LibraryInterface, NoRootStatesNoInterface) { +UTest no_root_states_no_interface{"LibraryInterface/NoRootStatesNoInterface", [] { Tree t("noroot"); Graph g; g.units.push_back(unit(t.touch("src/a.cppm"), "acme.a")); @@ -66,11 +67,11 @@ TEST(LibraryInterface, NoRootStatesNoInterface) { mcpp::manifest::Manifest m; m.package.name = "lib"; auto iface = library_interface(g, m, t.root, "acme.lib", {}); - EXPECT_TRUE(iface.rootModule.empty()); - EXPECT_EQ(iface.publicModules, iface.exportedModules); -} + expect(iface.rootModule.empty()); + expect(eq(iface.publicModules, iface.exportedModules)); +}}; -TEST(LibraryInterface, ADeclaredRootIsTheOneUsed) { +UTest a_declared_root_is_the_one_used{"LibraryInterface/ADeclaredRootIsTheOneUsed", [] { Tree t("declared"); Graph g; g.units.push_back(unit(t.touch("src/lib.cppm"), "acme.conventional")); @@ -79,12 +80,12 @@ TEST(LibraryInterface, ADeclaredRootIsTheOneUsed) { m.package.name = "lib"; m.lib.path = "rules/facade.cppm"; auto iface = library_interface(g, m, t.root, "acme.lib", {}); - EXPECT_TRUE(iface.rootDeclared); - EXPECT_EQ(iface.rootModule, "acme.facade"); - EXPECT_EQ(iface.publicModules, (std::set{"acme.facade"})); -} + expect(bool(iface.rootDeclared)); + expect(eq(iface.rootModule, "acme.facade")); + expect(eq(iface.publicModules, (std::set{"acme.facade"}))); +}}; -TEST(LibraryInterface, AnActiveFeatureAddsItsInterfaceUnits) { +UTest an_active_feature_adds_its_interface_units{"LibraryInterface/AnActiveFeatureAddsItsInterfaceUnits", [] { Tree t("features"); Graph g; g.units.push_back(unit(t.touch("src/lib.cppm"), "acme.lib")); @@ -95,10 +96,10 @@ TEST(LibraryInterface, AnActiveFeatureAddsItsInterfaceUnits) { m.buildConfig.featureSources["rules-cuda"] = {"rules/cuda.cppm"}; m.buildConfig.featureSources["rules-spirv"] = {"rules/spirv.cppm"}; auto iface = library_interface(g, m, t.root, "acme.lib", {"rules-cuda"}); - EXPECT_EQ(iface.publicModules, (std::set{"acme.lib", "acme.rules.cuda"})); -} + expect(eq(iface.publicModules, (std::set{"acme.lib", "acme.rules.cuda"}))); +}}; -TEST(LibraryInterface, OnlyThePackagesOwnUnitsCount) { +UTest only_the_packages_own_units_count{"LibraryInterface/OnlyThePackagesOwnUnitsCount", [] { Tree t("own"); Graph g; g.units.push_back(unit(t.touch("src/lib.cppm"), "acme.lib")); @@ -108,5 +109,5 @@ TEST(LibraryInterface, OnlyThePackagesOwnUnitsCount) { mcpp::manifest::Manifest m; m.package.name = "lib"; auto iface = library_interface(g, m, t.root, "acme.lib", {}); - EXPECT_FALSE(iface.exportedModules.contains("other.mod")); -} + expect(!iface.exportedModules.contains("other.mod")); +}}; diff --git a/tests/unit/test_link_intent_spelling.cpp b/tests/unit/test_link_intent_spelling.cpp index cbed0b613..d686a41b3 100644 --- a/tests/unit/test_link_intent_spelling.cpp +++ b/tests/unit/test_link_intent_spelling.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.flags; import mcpp.manifest; +using namespace mcpplibs::mtest; + using mcpp::build::LinkIntentFlavor; using mcpp::build::render_link_intent_flags; @@ -35,22 +37,22 @@ mcpp::manifest::LinkIntent leg_intent() { } // namespace -TEST(LinkIntentSpelling, MsvcGetsLibpathAndADotLib) { +UTest msvc_gets_libpath_and_a_dot_lib{"LinkIntentSpelling/MsvcGetsLibpathAndADotLib", [] { auto s = render_link_intent_flags(leg_intent(), LinkIntentFlavor::PeMsvc); // `/LIBPATH` without the colon: this is NINJA text, not a shell command // line, and `:` is escaped to `$:` because ninja reads a bare colon as the // outputs/inputs separator. Asserting the raw `/LIBPATH:` fails against a // perfectly correct renderer — which is what the first version of this test // did. - EXPECT_NE(s.find("/LIBPATH"), std::string::npos) << s; - EXPECT_NE(s.find("mathkit.lib"), std::string::npos) << s; + expect(ne(s.find("/LIBPATH"), std::string::npos)) << s; + expect(ne(s.find("mathkit.lib"), std::string::npos)) << s; // The GNU spelling must be absent, not merely outnumbered: `cl` stops at // the first `-L` it does not recognise. - EXPECT_EQ(s.find("-L"), std::string::npos) << s; - EXPECT_EQ(s.find("-lmathkit"), std::string::npos) << s; -} + expect(eq(s.find("-L"), std::string::npos)) << s; + expect(eq(s.find("-lmathkit"), std::string::npos)) << s; +}}; -TEST(LinkIntentSpelling, EveryDriverFlavourGetsTheGnuSpelling) { +UTest every_driver_flavour_gets_the_gnu_spelling{"LinkIntentSpelling/EveryDriverFlavourGetsTheGnuSpelling", [] { // PeGnu is not only MinGW: clang targeting the MSVC ABI lands here too, // because it is a compiler driver and takes `-L`. Handing it `/LIBPATH:` // because its OUTPUT is MSVC-ABI would be the mistake this test exists to @@ -58,25 +60,25 @@ TEST(LinkIntentSpelling, EveryDriverFlavourGetsTheGnuSpelling) { for (auto flavour : { LinkIntentFlavor::Elf, LinkIntentFlavor::MachO, LinkIntentFlavor::PeGnu }) { auto s = render_link_intent_flags(leg_intent(), flavour); - EXPECT_NE(s.find("-L"), std::string::npos) << s; - EXPECT_NE(s.find("-lmathkit"), std::string::npos) << s; - EXPECT_EQ(s.find("/LIBPATH:"), std::string::npos) << s; + expect(ne(s.find("-L"), std::string::npos)) << s; + expect(ne(s.find("-lmathkit"), std::string::npos)) << s; + expect(eq(s.find("/LIBPATH:"), std::string::npos)) << s; } -} +}}; -TEST(LinkIntentSpelling, AnExplicitTokenIsPassedThroughUntouched) { +UTest an_explicit_token_is_passed_through_untouched{"LinkIntentSpelling/AnExplicitTokenIsPassedThroughUntouched", [] { // A library named with a path or an extension is already a file, not a // name to decorate. Decorating it would produce `lib/x.lib.lib`. mcpp::manifest::LinkIntent i; i.libraries.push_back("lib/x86_64-windows-msvc/mathkit.lib"); auto s = render_link_intent_flags(i, LinkIntentFlavor::PeMsvc); - EXPECT_NE(s.find("mathkit.lib"), std::string::npos) << s; - EXPECT_EQ(s.find("mathkit.lib.lib"), std::string::npos) << s; -} + expect(ne(s.find("mathkit.lib"), std::string::npos)) << s; + expect(eq(s.find("mathkit.lib.lib"), std::string::npos)) << s; +}}; -TEST(LinkIntentSpelling, AnEmptyIntentRendersNothing) { +UTest an_empty_intent_renders_nothing{"LinkIntentSpelling/AnEmptyIntentRendersNothing", [] { // The generated manifest omits the block for legs that do not need it, and // an empty intent must not put a stray flag on the line. - EXPECT_TRUE(render_link_intent_flags({}, LinkIntentFlavor::PeMsvc).empty()); - EXPECT_TRUE(render_link_intent_flags({}, LinkIntentFlavor::Elf).empty()); -} + expect(render_link_intent_flags({}, LinkIntentFlavor::PeMsvc).empty()); + expect(render_link_intent_flags({}, LinkIntentFlavor::Elf).empty()); +}}; diff --git a/tests/unit/test_link_line.cpp b/tests/unit/test_link_line.cpp index 5a8d2def4..3f46a4bc2 100644 --- a/tests/unit/test_link_line.cpp +++ b/tests/unit/test_link_line.cpp @@ -9,11 +9,13 @@ // So these assertions are about the ORDER itself, not about "the flags look // roughly right". Each one names the failure it prevents. -#include import std; +import mcpplibs.mtest; import mcpp.build.link_line; +using namespace mcpplibs::mtest; + namespace ll = mcpp::build::link_line; namespace { @@ -23,7 +25,7 @@ namespace { // two huge numbers and no clue which one was missing. std::size_t pos_of(std::string_view haystack, std::string_view needle) { auto p = haystack.find(needle); - EXPECT_NE(p, std::string_view::npos) << "missing '" << needle << "' in: " << haystack; + expect(ne(p, std::string_view::npos)) << "missing '" << needle << "' in: " << haystack; return p; } @@ -42,66 +44,64 @@ ll::UnitTail full_tail() { // resolved against; the farm is a mutable view that may hold another build of // the same SONAME. Farm-first is how an already-linked artifact silently // starts loading a different library. -TEST(LinkLine, ArtifactDirectoryPrecedesTheRuntimeFallback) { +UTest artifact_directory_precedes_the_runtime_fallback{"LinkLine/ArtifactDirectoryPrecedesTheRuntimeFallback", [] { auto line = full_tail().render(); - EXPECT_LT(pos_of(line, "$ORIGIN"), pos_of(line, "subos/default/lib")) << line; -} + expect(lt(pos_of(line, "$ORIGIN"), pos_of(line, "subos/default/lib"))) << line; +}}; // ld honours the LAST `--enable/--disable-new-dtags` it sees, and both gcc // specs and clang config files supply the opposite of what mcpp wants. A slot // appended after this one silently changes RPATH into RUNPATH (or back), which // changes whether the tag applies to transitive dependencies at all. -TEST(LinkLine, LoaderTagIsLiterallyLast) { +UTest loader_tag_is_literally_last{"LinkLine/LoaderTagIsLiterallyLast", [] { auto line = full_tail().render(); - EXPECT_TRUE(line.ends_with("-Wl,--disable-new-dtags")) + expect(line.ends_with("-Wl,--disable-new-dtags")) << "something was emitted after the loader tag: " << line; - EXPECT_GT(pos_of(line, "--disable-new-dtags"), - pos_of(line, "subos/default/lib")) << line; -} + expect(gt(pos_of(line, "--disable-new-dtags"), pos_of(line, "subos/default/lib"))) << line; +}}; // The C++ runtime archive is processed after the dependencies it might // otherwise pre-empt: an archive member is pulled only for symbols still // undefined when the archive is reached. -TEST(LinkLine, CxxRuntimeFollowsDependenciesAndPrecedesTheFallback) { +UTest cxx_runtime_follows_dependencies_and_precedes_the_fallback{"LinkLine/CxxRuntimeFollowsDependenciesAndPrecedesTheFallback", [] { auto line = full_tail().render(); - EXPECT_LT(pos_of(line, "-lX11"), pos_of(line, "-static-libstdc++")) << line; - EXPECT_LT(pos_of(line, "-static-libstdc++"), pos_of(line, "subos/default/lib")) << line; -} + expect(lt(pos_of(line, "-lX11"), pos_of(line, "-static-libstdc++"))) << line; + expect(lt(pos_of(line, "-static-libstdc++"), pos_of(line, "subos/default/lib"))) << line; +}}; // PE has no rpath and no loader tag; Mach-O has no farm. A format that fills // only some slots must not acquire stray separators — the rendered line is // compared byte-for-byte against build.ninja by other tests. -TEST(LinkLine, EmptySlotsProduceNoStraySeparators) { +UTest empty_slots_produce_no_stray_separators{"LinkLine/EmptySlotsProduceNoStraySeparators", [] { ll::UnitTail t; t.cxxRuntime = " -static"; - EXPECT_EQ(t.render(), " -static"); + expect(eq(t.render(), " -static")); ll::UnitTail only_deps; only_deps.dependencies = " target/bin/foo.lib"; - EXPECT_EQ(only_deps.render(), " target/bin/foo.lib"); + expect(eq(only_deps.render(), " target/bin/foo.lib")); - EXPECT_EQ(ll::UnitTail{}.render(), ""); - EXPECT_TRUE(ll::UnitTail{}.empty()); - EXPECT_FALSE(full_tail().empty()); -} + expect(eq(ll::UnitTail{}.render(), "")); + expect(ll::UnitTail{}.empty()); + expect(!(full_tail().empty())); +}}; // A producer that forgets the leading space must not weld two flags together. // Every producer supplies it today; this keeps that from being load-bearing. -TEST(LinkLine, SlotWithoutLeadingSpaceGetsASeparator) { +UTest slot_without_leading_space_gets_a_separator{"LinkLine/SlotWithoutLeadingSpaceGetsASeparator", [] { ll::UnitTail t; t.dependencies = " -lfoo"; t.cxxRuntime = "-static-libstdc++"; // no leading space - EXPECT_EQ(t.render(), " -lfoo -static-libstdc++"); -} + expect(eq(t.render(), " -lfoo -static-libstdc++")); +}}; // Order is a property of the type, not of the caller's assignment order. -TEST(LinkLine, RenderOrderIsIndependentOfAssignmentOrder) { +UTest render_order_is_independent_of_assignment_order{"LinkLine/RenderOrderIsIndependentOfAssignmentOrder", [] { ll::UnitTail t; t.loaderTag = "-Wl,--disable-new-dtags"; t.runtimeFallback = " -Wl,-rpath,/farm"; t.cxxRuntime = " -static-libstdc++"; t.dependencies = " -Wl,-rpath,'$ORIGIN'"; - EXPECT_EQ(t.render(), - " -Wl,-rpath,'$ORIGIN' -static-libstdc++ -Wl,-rpath,/farm" - " -Wl,--disable-new-dtags"); -} + expect(eq(t.render(), " -Wl,-rpath,'$ORIGIN' -static-libstdc++ -Wl,-rpath,/farm" + " -Wl,--disable-new-dtags")); +}}; diff --git a/tests/unit/test_link_model_runtime_dirs.cpp b/tests/unit/test_link_model_runtime_dirs.cpp index a01914481..0bca89755 100644 --- a/tests/unit/test_link_model_runtime_dirs.cpp +++ b/tests/unit/test_link_model_runtime_dirs.cpp @@ -20,14 +20,16 @@ // passed with the defect in place. The link model takes its mode from its // input, so a test can name the mode instead of hoping for it. -#include import std; +import mcpplibs.mtest; import mcpp.build.flags; import mcpp.manifest.types; import mcpp.toolchain.linkmodel; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + namespace tc = mcpp::toolchain; namespace { @@ -80,48 +82,46 @@ std::string joined(const tc::ToolchainLinkModel& lm) { return s; } -TEST(LinkModelRuntimeDirs, PayloadFirstCarriesTheCompilerRuntime) { +UTest payload_first_carries_the_compiler_runtime{"LinkModelRuntimeDirs/PayloadFirstCarriesTheCompilerRuntime", [] { Payload p; auto lm = tc::resolve_link_model(p.toolchain(/*withSysroot=*/false)); - ASSERT_EQ(lm.mode, tc::CLibMode::PayloadFirst); + expect(fatal(eq(lm.mode, tc::CLibMode::PayloadFirst))); auto line = joined(lm); - EXPECT_NE(line.find("-Wl,-rpath," + p.gccLib.string()), std::string::npos) + expect(ne(line.find("-Wl,-rpath," + p.gccLib.string()), std::string::npos)) << "libgcc_s.so.1 lives here and nothing else will find it:\n" << line; -} +}}; -TEST(LinkModelRuntimeDirs, SysrootCarriesTheCompilerRuntime) { +UTest sysroot_carries_the_compiler_runtime{"LinkModelRuntimeDirs/SysrootCarriesTheCompilerRuntime", [] { Payload p; auto lm = tc::resolve_link_model(p.toolchain(/*withSysroot=*/true)); - ASSERT_EQ(lm.mode, tc::CLibMode::Sysroot); + expect(fatal(eq(lm.mode, tc::CLibMode::Sysroot))); auto line = joined(lm); - EXPECT_NE(line.find("-Wl,-rpath," + p.gccLib.string()), std::string::npos) + expect(ne(line.find("-Wl,-rpath," + p.gccLib.string()), std::string::npos)) << line; -} +}}; // The C library's own directory, in both modes, for the same reason. -TEST(LinkModelRuntimeDirs, BothModesCarryTheCLibrary) { +UTest both_modes_carry_the_c_library{"LinkModelRuntimeDirs/BothModesCarryTheCLibrary", [] { Payload p; for (bool withSysroot : {false, true}) { auto line = joined(tc::resolve_link_model(p.toolchain(withSysroot))); - EXPECT_NE(line.find("-Wl,-rpath," + p.glibcLib.string()), - std::string::npos) + expect(ne(line.find("-Wl,-rpath," + p.glibcLib.string()), std::string::npos)) << "withSysroot=" << withSysroot << "\n" << line; } -} +}}; // And the interpreter. A sysroot says where headers live; it says nothing // about which loader runs the result, and gcc's `*link:` no longer answers. -TEST(LinkModelRuntimeDirs, BothModesNameTheInterpreter) { +UTest both_modes_name_the_interpreter{"LinkModelRuntimeDirs/BothModesNameTheInterpreter", [] { Payload p; for (bool withSysroot : {false, true}) { auto line = joined(tc::resolve_link_model(p.toolchain(withSysroot))); - EXPECT_NE(line.find("--dynamic-linker=" + p.glibcLib.string()), - std::string::npos) + expect(ne(line.find("--dynamic-linker=" + p.glibcLib.string()), std::string::npos)) << "withSysroot=" << withSysroot << "\n" << line; } -} +}}; -TEST(LinkIntent, KeepsLinkSearchTransitiveSearchAndRuntimeSearchSeparate) { +UTest keeps_link_search_transitive_search_and_runtime_search_separate{"LinkIntent/KeepsLinkSearchTransitiveSearchAndRuntimeSearchSeparate", [] { mcpp::manifest::LinkIntent intent; intent.libraries = {"widget"}; intent.linkLibraryDirs = {"/contract/link"}; @@ -132,26 +132,26 @@ TEST(LinkIntent, KeepsLinkSearchTransitiveSearchAndRuntimeSearchSeparate) { auto elf = mcpp::build::render_link_intent_flags( intent, mcpp::build::LinkIntentFlavor::Elf); - EXPECT_NE(elf.find("-L/contract/link"), std::string::npos) << elf; - EXPECT_NE(elf.find("-Wl,-rpath-link,/contract/needed"), std::string::npos) + expect(ne(elf.find("-L/contract/link"), std::string::npos)) << elf; + expect(ne(elf.find("-Wl,-rpath-link,/contract/needed"), std::string::npos)) << elf; - EXPECT_NE(elf.find("-Wl,-rpath,/contract/run"), std::string::npos) << elf; - EXPECT_EQ(elf.find("-L/contract/run"), std::string::npos) << elf; - EXPECT_EQ(elf.find("widget.dll"), std::string::npos) << elf; + expect(ne(elf.find("-Wl,-rpath,/contract/run"), std::string::npos)) << elf; + expect(eq(elf.find("-L/contract/run"), std::string::npos)) << elf; + expect(eq(elf.find("widget.dll"), std::string::npos)) << elf; auto macho = mcpp::build::render_link_intent_flags( intent, mcpp::build::LinkIntentFlavor::MachO); - EXPECT_NE(macho.find("-Wl,-rpath,/contract/run"), std::string::npos) + expect(ne(macho.find("-Wl,-rpath,/contract/run"), std::string::npos)) << macho; - EXPECT_NE(macho.find("-framework WindowKit"), std::string::npos) << macho; - EXPECT_EQ(macho.find("rpath-link"), std::string::npos) << macho; + expect(ne(macho.find("-framework WindowKit"), std::string::npos)) << macho; + expect(eq(macho.find("rpath-link"), std::string::npos)) << macho; auto pe = mcpp::build::render_link_intent_flags( intent, mcpp::build::LinkIntentFlavor::PeMsvc); - EXPECT_NE(pe.find("/LIBPATH$:/contract/link"), std::string::npos) << pe; - EXPECT_EQ(pe.find("rpath"), std::string::npos) << pe; - EXPECT_EQ(pe.find("/contract/run"), std::string::npos) << pe; - EXPECT_EQ(pe.find("widget.dll"), std::string::npos) << pe; -} + expect(ne(pe.find("/LIBPATH$:/contract/link"), std::string::npos)) << pe; + expect(eq(pe.find("rpath"), std::string::npos)) << pe; + expect(eq(pe.find("/contract/run"), std::string::npos)) << pe; + expect(eq(pe.find("widget.dll"), std::string::npos)) << pe; +}}; } // namespace diff --git a/tests/unit/test_linkage_form.cpp b/tests/unit/test_linkage_form.cpp index 2a3099f02..d7e489720 100644 --- a/tests/unit/test_linkage_form.cpp +++ b/tests/unit/test_linkage_form.cpp @@ -6,11 +6,13 @@ // against measurements, so they are regression tests for a decision as much as // for code: the `-L` rule (§4.2) and the libc-linkage coupling (§12.3). -#include import std; +import mcpplibs.mtest; import mcpp.build.linkage_form; +using namespace mcpplibs::mtest; + namespace lf = mcpp::build::linkage_form; namespace { @@ -35,72 +37,71 @@ lf::Request want(lf::DepLinkage linkage) { // ── the default is byte-for-byte the old behaviour ───────────────────────── -TEST(LinkageForm, DefaultRequestIsStaticAndSilent) { +UTest default_request_is_static_and_silent{"LinkageForm/DefaultRequestIsStaticAndSilent", [] { lf::Request none; // nobody wrote anything - EXPECT_EQ(none.whole, lf::DepLinkage::Static); - EXPECT_FALSE(none.wholeIsExplicit); + expect(eq(none.whole, lf::DepLinkage::Static)); + expect(!none.wholeIsExplicit); auto pkg = source_package(); auto answer = lf::resolve(pkg, lf::admissible(pkg, hosted()), none); - EXPECT_EQ(answer.linkage, lf::DepLinkage::Static); - EXPECT_TRUE(answer.diagnostic.empty()); -} + expect(eq(answer.linkage, lf::DepLinkage::Static)); + expect(answer.diagnostic.empty()); +}}; -TEST(LinkageForm, ASourcePackageCanBeEitherForm) { +UTest a_source_package_can_be_either_form{"LinkageForm/ASourcePackageCanBeEitherForm", [] { auto pkg = source_package(); auto allowed = lf::admissible(pkg, hosted()); - EXPECT_TRUE(allowed.staticOk); - EXPECT_TRUE(allowed.sharedOk); - EXPECT_EQ(lf::resolve(pkg, allowed, want(lf::DepLinkage::Shared)).linkage, - lf::DepLinkage::Shared); -} + expect(bool(allowed.staticOk)); + expect(bool(allowed.sharedOk)); + expect(eq(lf::resolve(pkg, allowed, want(lf::DepLinkage::Shared)).linkage, lf::DepLinkage::Shared)); +}}; // ── `kind = "shared"` is a constraint; `kind = "lib"` is not ─────────────── -TEST(LinkageForm, DeclaredSharedIsAConstraintNotAPreference) { +UTest declared_shared_is_a_constraint_not_a_preference{"LinkageForm/DeclaredSharedIsAConstraintNotAPreference", [] { auto pkg = source_package(); pkg.declaredShared = true; auto allowed = lf::admissible(pkg, hosted()); - EXPECT_FALSE(allowed.staticOk); - EXPECT_TRUE(allowed.sharedOk); + expect(!allowed.staticOk); + expect(bool(allowed.sharedOk)); // Asking for static does not get it, and says so. auto answer = lf::resolve(pkg, allowed, want(lf::DepLinkage::Static)); - EXPECT_EQ(answer.linkage, lf::DepLinkage::Shared); - EXPECT_FALSE(answer.diagnostic.empty()); -} + expect(eq(answer.linkage, lf::DepLinkage::Shared)); + expect(!answer.diagnostic.empty()); +}}; -TEST(LinkageForm, NotDeclaringSharedIsNotAConstraintToBeStatic) { +UTest not_declaring_shared_is_not_a_constraint_to_be_static{"LinkageForm/NotDeclaringSharedIsNotAConstraintToBeStatic", [] { // `kind = "lib"` is the parser's DEFAULT, written as boilerplate by 84 of // the 130 packages in mcpp-index. Reading it as "must be static" would // lock the whole ecosystem out of this axis, so absence must stay silent. auto pkg = source_package(); - ASSERT_FALSE(pkg.declaredShared); - EXPECT_TRUE(lf::admissible(pkg, hosted()).sharedOk); -} + expect(fatal(!pkg.declaredShared)); + expect(bool(lf::admissible(pkg, hosted()).sharedOk)); +}}; // ── the `-L` rule (round-two correction) ─────────────────────────────────── -TEST(LinkageForm, ForeignLinkInputsAreRecognisedInEverySpelling) { - EXPECT_TRUE(lf::carries_foreign_link_inputs( - std::vector{"-Llib", "-l:libssl.a"})); - EXPECT_TRUE(lf::carries_foreign_link_inputs( - std::vector{"-L", "lib"})); - EXPECT_TRUE(lf::carries_foreign_link_inputs( - std::vector{"-Wl,-Llib"})); - EXPECT_TRUE(lf::carries_foreign_link_inputs( - std::vector{"/LIBPATH:lib"})); -} - -TEST(LinkageForm, SystemLibrariesAreNotForeignLinkInputs) { +UTest foreign_link_inputs_are_recognised_in_every_spelling{"LinkageForm/ForeignLinkInputsAreRecognisedInEverySpelling", [] { + expect(bool(lf::carries_foreign_link_inputs( + std::vector{"-Llib", "-l:libssl.a"}))); + expect(bool(lf::carries_foreign_link_inputs( + std::vector{"-L", "lib"}))); + expect(bool(lf::carries_foreign_link_inputs( + std::vector{"-Wl,-Llib"}))); + expect(bool(lf::carries_foreign_link_inputs( + std::vector{"/LIBPATH:lib"}))); +}}; + +UTest system_libraries_are_not_foreign_link_inputs{"LinkageForm/SystemLibrariesAreNotForeignLinkInputs", [] { // The 27 mcpp-index packages whose ldflags name only host libraries must // stay eligible: a shared object depending on libm is ordinary. - EXPECT_FALSE(lf::carries_foreign_link_inputs( + expect(!lf::carries_foreign_link_inputs( std::vector{"-lm", "-lpthread", "-ldl", "-lrt"})); - EXPECT_FALSE(lf::carries_foreign_link_inputs( + expect(!lf::carries_foreign_link_inputs( std::vector{"-lws2_32", "-lbcrypt", "-ladvapi32"})); -} +}}; -TEST(LinkageForm, APackageCarryingPrebuiltArchivesCannotBeShared) { +UTest a_package_carrying_prebuilt_archives_cannot_be_shared{"LinkageForm/APackageCarryingPrebuiltArchivesCannotBeShared", [] { // compat.openssl's shape: one anchor TU that mcpp compiles, plus `.a` // archives it ships. Wrapping somebody else's non-PIC archive in a shared // object is not something mcpp can do, so the request is refused WITH a @@ -109,35 +110,34 @@ TEST(LinkageForm, APackageCarryingPrebuiltArchivesCannotBeShared) { pkg.label = "compat.openssl@3.5.0"; pkg.carriesForeignLinkInputs = true; auto allowed = lf::admissible(pkg, hosted()); - EXPECT_TRUE(allowed.staticOk); - EXPECT_FALSE(allowed.sharedOk); - EXPECT_NE(allowed.sharedRefusal.find("-L"), std::string::npos); + expect(bool(allowed.staticOk)); + expect(!allowed.sharedOk); + expect(ne(allowed.sharedRefusal.find("-L"), std::string::npos)); auto answer = lf::resolve(pkg, allowed, want(lf::DepLinkage::Shared)); - EXPECT_EQ(answer.linkage, lf::DepLinkage::Static); - EXPECT_FALSE(answer.diagnostic.empty()); -} + expect(eq(answer.linkage, lf::DepLinkage::Static)); + expect(!answer.diagnostic.empty()); +}}; -TEST(LinkageForm, APackageWithNoSourcesOfItsOwnCannotBeShared) { +UTest a_package_with_no_sources_of_its_own_cannot_be_shared{"LinkageForm/APackageWithNoSourcesOfItsOwnCannotBeShared", [] { lf::PackageFacts pkg; pkg.label = "vendor.glib@2.80.0"; pkg.hasSources = false; - EXPECT_FALSE(lf::admissible(pkg, hosted()).sharedOk); -} + expect(!(lf::admissible(pkg, hosted()).sharedOk)); +}}; // ── the target's own veto (round-two correction) ─────────────────────────── -TEST(LinkageForm, AFreestandingTargetHasNothingToLoadASharedLibraryWith) { +UTest a_freestanding_target_has_nothing_to_load_a_shared_library_with{"LinkageForm/AFreestandingTargetHasNothingToLoadASharedLibraryWith", [] { auto pkg = source_package(); lf::TargetFacts bare; bare.hasLoader = false; auto allowed = lf::admissible(pkg, bare); - EXPECT_FALSE(allowed.sharedOk); - EXPECT_EQ(lf::resolve(pkg, allowed, want(lf::DepLinkage::Shared)).linkage, - lf::DepLinkage::Static); -} + expect(!allowed.sharedOk); + expect(eq(lf::resolve(pkg, allowed, want(lf::DepLinkage::Shared)).linkage, lf::DepLinkage::Static)); +}}; -TEST(LinkageForm, AFullyStaticLibcVetoesTheSharedForm) { +UTest a_fully_static_libc_vetoes_the_shared_form{"LinkageForm/AFullyStaticLibcVetoesTheSharedForm", [] { // The two axes named `linkage` are NOT independent. A `-static` image // has no interpreter, so it cannot load a shared object — and musl // defaults to `linkage = "static"`, which makes this the COMMON path @@ -146,16 +146,16 @@ TEST(LinkageForm, AFullyStaticLibcVetoesTheSharedForm) { lf::TargetFacts staticLibc; staticLibc.fullStaticLibc = true; auto allowed = lf::admissible(pkg, staticLibc); - EXPECT_FALSE(allowed.sharedOk); - EXPECT_NE(allowed.sharedRefusal.find("static"), std::string::npos); + expect(!allowed.sharedOk); + expect(ne(allowed.sharedRefusal.find("static"), std::string::npos)); auto answer = lf::resolve(pkg, allowed, want(lf::DepLinkage::Shared)); - EXPECT_EQ(answer.linkage, lf::DepLinkage::Static); + expect(eq(answer.linkage, lf::DepLinkage::Static)); // It must SAY why, or the user edits the wrong key forever. - EXPECT_FALSE(answer.diagnostic.empty()); -} + expect(!answer.diagnostic.empty()); +}}; -TEST(LinkageForm, TheTargetVetoIsReportedBeforeThePackageOne) { +UTest the_target_veto_is_reported_before_the_package_one{"LinkageForm/TheTargetVetoIsReportedBeforeThePackageOne", [] { // Order is the order of the diagnostic: a reason the user cannot fix by // editing the package must not point at the package. lf::PackageFacts pkg = source_package(); @@ -163,36 +163,35 @@ TEST(LinkageForm, TheTargetVetoIsReportedBeforeThePackageOne) { lf::TargetFacts bare; bare.hasLoader = false; auto allowed = lf::admissible(pkg, bare); - EXPECT_EQ(allowed.sharedRefusal.find("compat.zlib"), std::string::npos); -} + expect(eq(allowed.sharedRefusal.find("compat.zlib"), std::string::npos)); +}}; // ── distribution packages answer from what they ship ─────────────────────── -TEST(LinkageForm, ADistributionPackageOffersOnlyTheLegsItShips) { +UTest a_distribution_package_offers_only_the_legs_it_ships{"LinkageForm/ADistributionPackageOffersOnlyTheLegsItShips", [] { lf::PackageFacts pkg; pkg.label = "acme.mathkit@0.1.0"; pkg.isDistribution = true; pkg.shipsStatic = true; auto allowed = lf::admissible(pkg, hosted()); - EXPECT_TRUE(allowed.staticOk); - EXPECT_FALSE(allowed.sharedOk); - EXPECT_NE(allowed.sharedRefusal.find("mathkit"), std::string::npos); + expect(bool(allowed.staticOk)); + expect(!allowed.sharedOk); + expect(ne(allowed.sharedRefusal.find("mathkit"), std::string::npos)); pkg.shipsShared = true; - EXPECT_TRUE(lf::admissible(pkg, hosted()).sharedOk); -} + expect(bool(lf::admissible(pkg, hosted()).sharedOk)); +}}; // ── per-package request ──────────────────────────────────────────────────── -TEST(LinkageForm, APerPackageRequestOverridesTheWholeGraphDefault) { +UTest a_per_package_request_overrides_the_whole_graph_default{"LinkageForm/APerPackageRequestOverridesTheWholeGraphDefault", [] { auto pkg = source_package(); lf::Request request; // default static, not explicit request.perPackage["compat.zlib@1.3.2"] = lf::DepLinkage::Shared; - EXPECT_EQ(lf::resolve(pkg, lf::admissible(pkg, hosted()), request).linkage, - lf::DepLinkage::Shared); -} + expect(eq(lf::resolve(pkg, lf::admissible(pkg, hosted()), request).linkage, lf::DepLinkage::Shared)); +}}; -TEST(LinkageForm, AnUnaskedForRefusalStaysSilent) { +UTest an_unasked_for_refusal_stays_silent{"LinkageForm/AnUnaskedForRefusalStaysSilent", [] { // mcpp promised nothing when nobody asked. A default that cannot be // honoured is not a broken promise, and warning about it on every build // puts noise on correct manifests. @@ -200,31 +199,31 @@ TEST(LinkageForm, AnUnaskedForRefusalStaysSilent) { pkg.declaredShared = true; // constrained to shared lf::Request none; // default static, NOT explicit auto answer = lf::resolve(pkg, lf::admissible(pkg, hosted()), none); - EXPECT_EQ(answer.linkage, lf::DepLinkage::Shared); - EXPECT_TRUE(answer.diagnostic.empty()); -} + expect(eq(answer.linkage, lf::DepLinkage::Shared)); + expect(answer.diagnostic.empty()); +}}; // ── PIC ──────────────────────────────────────────────────────────────────── -TEST(LinkageForm, PicFollowsAnySharedFormInTheGraph) { +UTest pic_follows_any_shared_form_in_the_graph{"LinkageForm/PicFollowsAnySharedFormInTheGraph", [] { using L = lf::DepLinkage; std::vector allStatic{L::Static, L::Static}; std::vector oneShared{L::Static, L::Shared}; - EXPECT_FALSE(lf::needs_pic(allStatic, /*anyOwnSharedTarget=*/false)); - EXPECT_TRUE(lf::needs_pic(allStatic, /*anyOwnSharedTarget=*/true)); - EXPECT_TRUE(lf::needs_pic(oneShared, /*anyOwnSharedTarget=*/false)); -} + expect(!lf::needs_pic(allStatic, /*anyOwnSharedTarget=*/false)); + expect(bool(lf::needs_pic(allStatic, /*anyOwnSharedTarget=*/true))); + expect(bool(lf::needs_pic(oneShared, /*anyOwnSharedTarget=*/false))); +}}; // ── vocabulary ───────────────────────────────────────────────────────────── -TEST(LinkageForm, TheVocabularyIsClosed) { - EXPECT_EQ(lf::parse("static"), lf::DepLinkage::Static); - EXPECT_EQ(lf::parse("shared"), lf::DepLinkage::Shared); - EXPECT_FALSE(lf::parse("dynamic").has_value()); // the libc axis' word - EXPECT_FALSE(lf::parse("").has_value()); - EXPECT_EQ(lf::to_string(lf::DepLinkage::Static), "static"); - EXPECT_EQ(lf::to_string(lf::DepLinkage::Shared), "shared"); -} +UTest the_vocabulary_is_closed{"LinkageForm/TheVocabularyIsClosed", [] { + expect(eq(lf::parse("static"), lf::DepLinkage::Static)); + expect(eq(lf::parse("shared"), lf::DepLinkage::Shared)); + expect(!(lf::parse("dynamic").has_value())); // the libc axis' word + expect(!(lf::parse("").has_value())); + expect(eq(lf::to_string(lf::DepLinkage::Static), "static")); + expect(eq(lf::to_string(lf::DepLinkage::Shared), "shared")); +}}; // ── a package's default form (#642 E1) ───────────────────────────────────── @@ -240,71 +239,68 @@ lf::PackageFacts defaulting_to(lf::DepLinkage form) { } // namespace -TEST(LinkageForm, APackageDefaultIsAnsweredWhenNobodyAsks) { +UTest a_package_default_is_answered_when_nobody_asks{"LinkageForm/APackageDefaultIsAnsweredWhenNobodyAsks", [] { auto pkg = defaulting_to(lf::DepLinkage::Shared); auto answer = lf::resolve(pkg, lf::admissible(pkg, hosted()), lf::Request{}); - EXPECT_EQ(answer.linkage, lf::DepLinkage::Shared); - EXPECT_EQ(answer.reason, "package-default"); - EXPECT_TRUE(answer.diagnostic.empty()); - EXPECT_TRUE(answer.note.empty()); -} + expect(eq(answer.linkage, lf::DepLinkage::Shared)); + expect(eq(answer.reason, "package-default")); + expect(answer.diagnostic.empty()); + expect(answer.note.empty()); +}}; -TEST(LinkageForm, APackageDefaultIsNotAConstraint) { +UTest a_package_default_is_not_a_constraint{"LinkageForm/APackageDefaultIsNotAConstraint", [] { // The difference from `kind = "shared"` is the whole feature: both forms // stay admissible, so a request for the other one is not refused. auto pkg = defaulting_to(lf::DepLinkage::Shared); auto allowed = lf::admissible(pkg, hosted()); - EXPECT_TRUE(allowed.staticOk); - EXPECT_TRUE(allowed.sharedOk); - EXPECT_TRUE(allowed.constraint.empty()); -} + expect(bool(allowed.staticOk)); + expect(bool(allowed.sharedOk)); + expect(allowed.constraint.empty()); +}}; -TEST(LinkageForm, AnEdgeRequestOverridesThePackageDefaultAndNamesBothStatements) { +UTest an_edge_request_overrides_the_package_default_and_names_both_statements{"LinkageForm/AnEdgeRequestOverridesThePackageDefaultAndNamesBothStatements", [] { auto pkg = defaulting_to(lf::DepLinkage::Shared); lf::Request request; request.perPackage[pkg.label] = lf::DepLinkage::Static; auto answer = lf::resolve(pkg, lf::admissible(pkg, hosted()), request); - EXPECT_EQ(answer.linkage, lf::DepLinkage::Static); - EXPECT_EQ(answer.reason, "requested"); + expect(eq(answer.linkage, lf::DepLinkage::Static)); + expect(eq(answer.reason, "requested")); // Honoured, so not a degradation: `--strict` must accept it. - EXPECT_TRUE(answer.diagnostic.empty()); - EXPECT_NE(answer.note.find("`linkage = \"static\"` on this dependency"), - std::string::npos) << answer.note; - EXPECT_NE(answer.note.find("[targets.zlib] linkage = \"shared\""), - std::string::npos) << answer.note; -} + expect(answer.diagnostic.empty()); + expect(ne(answer.note.find("`linkage = \"static\"` on this dependency"), std::string::npos)) << answer.note; + expect(ne(answer.note.find("[targets.zlib] linkage = \"shared\""), std::string::npos)) << answer.note; +}}; -TEST(LinkageForm, AWrittenWholeGraphValueOutranksThePackageDefault) { +UTest a_written_whole_graph_value_outranks_the_package_default{"LinkageForm/AWrittenWholeGraphValueOutranksThePackageDefault", [] { auto pkg = defaulting_to(lf::DepLinkage::Shared); auto answer = lf::resolve(pkg, lf::admissible(pkg, hosted()), want(lf::DepLinkage::Static)); - EXPECT_EQ(answer.linkage, lf::DepLinkage::Static); - EXPECT_EQ(answer.reason, "requested"); - EXPECT_NE(answer.note.find("dependency_linkage = \"static\""), std::string::npos) + expect(eq(answer.linkage, lf::DepLinkage::Static)); + expect(eq(answer.reason, "requested")); + expect(ne(answer.note.find("dependency_linkage = \"static\""), std::string::npos)) << answer.note; -} +}}; -TEST(LinkageForm, AnUnwrittenWholeGraphValueDoesNotOutrankThePackageDefault) { +UTest an_unwritten_whole_graph_value_does_not_outrank_the_package_default{"LinkageForm/AnUnwrittenWholeGraphValueDoesNotOutrankThePackageDefault", [] { // `whole` is static by construction; only a human writing it makes it a // statement, and mcpp's own default is exactly what a package default // replaces. auto pkg = defaulting_to(lf::DepLinkage::Shared); lf::Request none; - ASSERT_EQ(none.whole, lf::DepLinkage::Static); - EXPECT_EQ(lf::resolve(pkg, lf::admissible(pkg, hosted()), none).linkage, - lf::DepLinkage::Shared); -} + expect(fatal(eq(none.whole, lf::DepLinkage::Static))); + expect(eq(lf::resolve(pkg, lf::admissible(pkg, hosted()), none).linkage, lf::DepLinkage::Shared)); +}}; -TEST(LinkageForm, ARequestThatAgreesWithTheDefaultPrintsNothing) { +UTest a_request_that_agrees_with_the_default_prints_nothing{"LinkageForm/ARequestThatAgreesWithTheDefaultPrintsNothing", [] { auto pkg = defaulting_to(lf::DepLinkage::Shared); lf::Request request; request.perPackage[pkg.label] = lf::DepLinkage::Shared; auto answer = lf::resolve(pkg, lf::admissible(pkg, hosted()), request); - EXPECT_EQ(answer.reason, "requested"); - EXPECT_TRUE(answer.note.empty()); -} + expect(eq(answer.reason, "requested")); + expect(answer.note.empty()); +}}; -TEST(LinkageForm, ADefaultTheTargetCannotHonourFallsBackWithoutAWord) { +UTest a_default_the_target_cannot_honour_falls_back_without_a_word{"LinkageForm/ADefaultTheTargetCannotHonourFallsBackWithoutAWord", [] { // Nobody asked, so there is no broken promise to report: the default is // the package's preference, and a fully static image cannot load a // shared library at all. @@ -312,8 +308,8 @@ TEST(LinkageForm, ADefaultTheTargetCannotHonourFallsBackWithoutAWord) { lf::TargetFacts target; target.fullStaticLibc = true; auto answer = lf::resolve(pkg, lf::admissible(pkg, target), lf::Request{}); - EXPECT_EQ(answer.linkage, lf::DepLinkage::Static); - EXPECT_EQ(answer.reason, "static-libc"); - EXPECT_TRUE(answer.diagnostic.empty()); - EXPECT_TRUE(answer.note.empty()); -} + expect(eq(answer.linkage, lf::DepLinkage::Static)); + expect(eq(answer.reason, "static-libc")); + expect(answer.diagnostic.empty()); + expect(answer.note.empty()); +}}; diff --git a/tests/unit/test_linkmodel.cpp b/tests/unit/test_linkmodel.cpp index bf5710b30..3674de779 100644 --- a/tests/unit/test_linkmodel.cpp +++ b/tests/unit/test_linkmodel.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.linkmodel; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + // The toolchain link model is the single resolver for "how do we compile and // link against this toolchain's C library" (issue #195 / the hermetic link // model design doc). These tests pin its three contracts: @@ -37,52 +39,49 @@ void touch(const std::filesystem::path& p) { std::string ident(const std::filesystem::path& p) { return p.string(); } -TEST(LoaderFilename, TripleMap) { - EXPECT_EQ(tc::loader_filename("x86_64-unknown-linux-gnu"), "ld-linux-x86-64.so.2"); - EXPECT_EQ(tc::loader_filename("aarch64-linux-gnu"), "ld-linux-aarch64.so.1"); - EXPECT_EQ(tc::loader_filename("x86_64-linux-musl"), "ld-musl-x86_64.so.1"); - EXPECT_EQ(tc::loader_filename("aarch64-linux-musl"), "ld-musl-aarch64.so.1"); - EXPECT_EQ(tc::loader_filename("wasm32-unknown-unknown"), ""); -} - -TEST(DistroLoaderPath, LsbLayoutPerArch) { - EXPECT_EQ(tc::distro_loader_path("x86_64-linux-gnu"), "/lib64/ld-linux-x86-64.so.2"); - EXPECT_EQ(tc::distro_loader_path("aarch64-linux-gnu"), "/lib/ld-linux-aarch64.so.1"); - EXPECT_EQ(tc::distro_loader_path("mips64-unknown"), ""); -} - -TEST(ResolveLoader, TripleMapHit) { +UTest triple_map{"LoaderFilename/TripleMap", [] { + expect(eq(tc::loader_filename("x86_64-unknown-linux-gnu"), "ld-linux-x86-64.so.2")); + expect(eq(tc::loader_filename("aarch64-linux-gnu"), "ld-linux-aarch64.so.1")); + expect(eq(tc::loader_filename("x86_64-linux-musl"), "ld-musl-x86_64.so.1")); + expect(eq(tc::loader_filename("aarch64-linux-musl"), "ld-musl-aarch64.so.1")); + expect(eq(tc::loader_filename("wasm32-unknown-unknown"), "")); +}}; + +UTest lsb_layout_per_arch{"DistroLoaderPath/LsbLayoutPerArch", [] { + expect(eq(tc::distro_loader_path("x86_64-linux-gnu"), "/lib64/ld-linux-x86-64.so.2")); + expect(eq(tc::distro_loader_path("aarch64-linux-gnu"), "/lib/ld-linux-aarch64.so.1")); + expect(eq(tc::distro_loader_path("mips64-unknown"), "")); +}}; + +UTest triple_map_hit{"ResolveLoader/TripleMapHit", [] { Tmp dir; auto lib = dir.path / "lib64"; touch(lib / "ld-linux-x86-64.so.2"); - EXPECT_EQ(tc::resolve_loader(lib, "x86_64-unknown-linux-gnu"), - lib / "ld-linux-x86-64.so.2"); -} + expect(eq(tc::resolve_loader(lib, "x86_64-unknown-linux-gnu"), lib / "ld-linux-x86-64.so.2")); +}}; -TEST(ResolveLoader, GlobFallbackForUnknownTriple) { +UTest glob_fallback_for_unknown_triple{"ResolveLoader/GlobFallbackForUnknownTriple", [] { Tmp dir; auto lib = dir.path / "lib"; touch(lib / "ld-linux-aarch64.so.1"); auto got = tc::resolve_loader(lib, ""); // no triple info at all - EXPECT_EQ(got, lib / "ld-linux-aarch64.so.1"); -} + expect(eq(got, lib / "ld-linux-aarch64.so.1")); +}}; -TEST(ResolveLoader, EmptyWhenNothingFound) { +UTest empty_when_nothing_found{"ResolveLoader/EmptyWhenNothingFound", [] { Tmp dir; auto lib = dir.path / "lib64"; std::filesystem::create_directories(lib); - EXPECT_TRUE(tc::resolve_loader(lib, "x86_64-linux-gnu").empty()); -} + expect(tc::resolve_loader(lib, "x86_64-linux-gnu").empty()); +}}; -TEST(PayloadLibDir, PrefersLib64ThenLib) { +UTest prefers_lib64_then_lib{"PayloadLibDir/PrefersLib64ThenLib", [] { Tmp dir; touch(dir.path / "lib" / "ld-linux-x86-64.so.2"); - EXPECT_EQ(tc::payload_lib_dir_with_loader(dir.path, "x86_64-linux-gnu"), - dir.path / "lib"); + expect(eq(tc::payload_lib_dir_with_loader(dir.path, "x86_64-linux-gnu"), dir.path / "lib")); touch(dir.path / "lib64" / "ld-linux-x86-64.so.2"); - EXPECT_EQ(tc::payload_lib_dir_with_loader(dir.path, "x86_64-linux-gnu"), - dir.path / "lib64"); -} + expect(eq(tc::payload_lib_dir_with_loader(dir.path, "x86_64-linux-gnu"), dir.path / "lib64")); +}}; // Fabricate a bundled-LLVM-style toolchain: bin/clang++ + bin/clang++.cfg, // libc++ headers, and a glibc payload. @@ -111,37 +110,37 @@ struct FakeClangWithPayload { } }; -TEST(LinkModel, ClangCfgPayloadFirstCarriesCrtDiscovery) { +UTest clang_cfg_payload_first_carries_crt_discovery{"LinkModel/ClangCfgPayloadFirstCarriesCrtDiscovery", [] { FakeClangWithPayload fx; auto lm = tc::resolve_link_model(fx.t); - EXPECT_EQ(lm.mode, tc::CLibMode::PayloadFirst); - EXPECT_TRUE(lm.clangWithCfg); - EXPECT_EQ(lm.crtDir, fx.glibcLib); - EXPECT_EQ(lm.loader, fx.glibcLib / "ld-linux-x86-64.so.2"); + expect(eq(lm.mode, tc::CLibMode::PayloadFirst)); + expect(bool(lm.clangWithCfg)); + expect(eq(lm.crtDir, fx.glibcLib)); + expect(eq(lm.loader, fx.glibcLib / "ld-linux-x86-64.so.2")); auto link = lm.link_flags(ident); // -B is the #195 fix: CRT objects resolve via -B, never -L. - EXPECT_NE(link.find(" -B" + fx.glibcLib.string()), std::string::npos); - EXPECT_NE(link.find(" -L" + fx.glibcLib.string()), std::string::npos); - EXPECT_NE(link.find("--dynamic-linker=" + lm.loader.string()), std::string::npos); + expect(ne(link.find(" -B" + fx.glibcLib.string()), std::string::npos)); + expect(ne(link.find(" -L" + fx.glibcLib.string()), std::string::npos)); + expect(ne(link.find("--dynamic-linker=" + lm.loader.string()), std::string::npos)); auto compile = lm.compile_flags(ident); - EXPECT_NE(compile.find("-nostdlibinc"), std::string::npos); - EXPECT_NE(compile.find("-isystem"), std::string::npos); - EXPECT_EQ(compile.find("-idirafter"), std::string::npos); + expect(ne(compile.find("-nostdlibinc"), std::string::npos)); + expect(ne(compile.find("-isystem"), std::string::npos)); + expect(eq(compile.find("-idirafter"), std::string::npos)); // `-nostdlibinc` already closes clang's host search; no -isysroot. - EXPECT_EQ(compile.find("-isysroot"), std::string::npos); -} + expect(eq(compile.find("-isysroot"), std::string::npos)); +}}; -TEST(LinkModel, ClangDriverModelExposesCfgAndHeaders) { +UTest clang_driver_model_exposes_cfg_and_headers{"LinkModel/ClangDriverModelExposesCfgAndHeaders", [] { FakeClangWithPayload fx; auto dm = tc::resolve_clang_driver(fx.t); - EXPECT_TRUE(dm.hasCfg); - ASSERT_FALSE(dm.cxxIncludes.empty()); - EXPECT_NE(dm.compile_flags(ident).find("--no-default-config"), std::string::npos); -} + expect(bool(dm.hasCfg)); + expect(fatal(!dm.cxxIncludes.empty())); + expect(ne(dm.compile_flags(ident).find("--no-default-config"), std::string::npos)); +}}; -TEST(LinkModel, GccSysrootWinsOverPayload) { +UTest gcc_sysroot_wins_over_payload{"LinkModel/GccSysrootWinsOverPayload", [] { Tmp dir; auto sysroot = dir.path / "sysroot"; touch(sysroot / "usr" / "include" / "stdlib.h"); @@ -157,17 +156,17 @@ TEST(LinkModel, GccSysrootWinsOverPayload) { .linuxInclude = dir.path / "linux" / "include", }; auto lm = tc::resolve_link_model(t); - EXPECT_EQ(lm.mode, tc::CLibMode::Sysroot); - EXPECT_NE(lm.compile_flags(ident).find("--sysroot="), std::string::npos); - EXPECT_EQ(lm.compile_flags(ident).find("-nostdlibinc"), std::string::npos); + expect(eq(lm.mode, tc::CLibMode::Sysroot)); + expect(ne(lm.compile_flags(ident).find("--sysroot="), std::string::npos)); + expect(eq(lm.compile_flags(ident).find("-nostdlibinc"), std::string::npos)); // --sysroot already replaces the recorded sysroot. - EXPECT_EQ(lm.compile_flags(ident).find("-isysroot"), std::string::npos); - EXPECT_NE(lm.link_flags(ident).find("--sysroot="), std::string::npos); + expect(eq(lm.compile_flags(ident).find("-isysroot"), std::string::npos)); + expect(ne(lm.link_flags(ident).find("--sysroot="), std::string::npos)); // Kernel headers exist in the sysroot → no supplement. - EXPECT_TRUE(lm.systemIncludes.empty()); -} + expect(lm.systemIncludes.empty()); +}}; -TEST(LinkModel, GccSysrootSupplementsMissingKernelHeaders) { +UTest gcc_sysroot_supplements_missing_kernel_headers{"LinkModel/GccSysrootSupplementsMissingKernelHeaders", [] { Tmp dir; auto sysroot = dir.path / "sysroot"; touch(sysroot / "usr" / "include" / "stdlib.h"); // no linux/limits.h @@ -181,12 +180,12 @@ TEST(LinkModel, GccSysrootSupplementsMissingKernelHeaders) { .linuxInclude = dir.path / "linux" / "include", }; auto lm = tc::resolve_link_model(t); - ASSERT_EQ(lm.systemIncludes.size(), 1u); + expect(fatal(eq(lm.systemIncludes.size(), 1u))); // Sysroot-mode supplement renders as -isystem even for GCC. - EXPECT_NE(lm.compile_flags(ident).find("-isystem"), std::string::npos); -} + expect(ne(lm.compile_flags(ident).find("-isystem"), std::string::npos)); +}}; -TEST(LinkModel, GccPayloadEmitsIdirafterAndItsOwnAddressing) { +UTest gcc_payload_emits_idirafter_and_its_own_addressing{"LinkModel/GccPayloadEmitsIdirafterAndItsOwnAddressing", [] { Tmp dir; auto glibcLib = dir.path / "glibc" / "lib64"; touch(glibcLib / "ld-linux-x86-64.so.2"); @@ -199,20 +198,20 @@ TEST(LinkModel, GccPayloadEmitsIdirafterAndItsOwnAddressing) { .linuxInclude = {}, }; auto lm = tc::resolve_link_model(t); - EXPECT_EQ(lm.mode, tc::CLibMode::PayloadFirst); + expect(eq(lm.mode, tc::CLibMode::PayloadFirst)); // Headers still differ by driver: libstdc++'s #include_next wrappers need // -idirafter, and that has not changed. - EXPECT_NE(lm.compile_flags(ident).find("-idirafter"), std::string::npos); - EXPECT_EQ(lm.compile_flags(ident).find("-nostdlibinc"), std::string::npos); + expect(ne(lm.compile_flags(ident).find("-idirafter"), std::string::npos)); + expect(eq(lm.compile_flags(ident).find("-nostdlibinc"), std::string::npos)); // The sysroot GCC recorded when it was built is replaced, for headers // only, by the C library payload's root: `-isysroot ` as two argv // words, so `/usr/include` is never searched. auto tokens = lm.compile_tokens(ident); auto it = std::ranges::find(tokens, std::string("-isysroot")); - ASSERT_NE(it, tokens.end()); - ASSERT_NE(std::next(it), tokens.end()); - EXPECT_EQ(*std::next(it), (dir.path / "glibc").string()); + expect(fatal(ne(it, tokens.end()))); + expect(fatal(ne(std::next(it), tokens.end()))); + expect(eq(*std::next(it), (dir.path / "glibc").string())); // Addressing no longer differs. GCC used to be left to its install-time // specs here, which made the RUN side a per-toolchain-install decision @@ -220,21 +219,20 @@ TEST(LinkModel, GccPayloadEmitsIdirafterAndItsOwnAddressing) { // glibc versions as soon as a second one was installed. mcpp already // refuses to depend on clang's install-time cfg for exactly this reason. auto link = lm.link_flags(ident); - EXPECT_NE(link.find(" -B"), std::string::npos); - EXPECT_NE(link.find("--dynamic-linker=" + lm.loader.string()), - std::string::npos); - EXPECT_NE(link.find("-Wl,-rpath," + glibcLib.string()), std::string::npos); -} + expect(ne(link.find(" -B"), std::string::npos)); + expect(ne(link.find("--dynamic-linker=" + lm.loader.string()), std::string::npos)); + expect(ne(link.find("-Wl,-rpath," + glibcLib.string()), std::string::npos)); +}}; -TEST(LinkModel, NothingUsableYieldsNoneAndEmptyFlags) { +UTest nothing_usable_yields_none_and_empty_flags{"LinkModel/NothingUsableYieldsNoneAndEmptyFlags", [] { tc::Toolchain t; t.compiler = tc::CompilerId::Clang; t.targetTriple = "x86_64-linux-gnu"; auto lm = tc::resolve_link_model(t); - EXPECT_EQ(lm.mode, tc::CLibMode::None); - EXPECT_TRUE(lm.compile_flags(ident).empty()); - EXPECT_TRUE(lm.link_flags(ident).empty()); -} + expect(eq(lm.mode, tc::CLibMode::None)); + expect(lm.compile_flags(ident).empty()); + expect(lm.link_flags(ident).empty()); +}}; // 关于 `find_sibling_tool` 的顺序:**不在这里测**。 @@ -266,21 +264,21 @@ TEST(LinkModel, NothingUsableYieldsNoneAndEmptyFlags) { // correct: `wasm-ld: error: unknown argument: // --dynamic-linker=/lib64/ld-linux-x86-64.so.2` -- this host's loader // handed to a WebAssembly linker. -TEST(LinkModel, AnSdkTargetDescribesNoCLibrary) { +UTest an_sdk_target_describes_no_c_library{"LinkModel/AnSdkTargetDescribesNoCLibrary", [] { for (auto target : {"wasm32-emscripten", "aarch64-linux-android", "x86_64-linux-android"}) { auto tc = mcpp::toolchain::Toolchain{}; tc.compiler = mcpp::toolchain::CompilerId::Clang; tc.targetTriple = target; auto lm = mcpp::toolchain::resolve_link_model(tc); - EXPECT_EQ(lm.mode, mcpp::toolchain::CLibMode::None) << target; - EXPECT_TRUE(lm.libDirs.empty()) << target; - EXPECT_TRUE(lm.crtDir.empty()) << target; + expect(eq(lm.mode, mcpp::toolchain::CLibMode::None)) << target; + expect(lm.libDirs.empty()) << target; + expect(lm.crtDir.empty()) << target; // And the rendered flags are empty, which is what both channels emit. - EXPECT_TRUE(lm.link_flags([](const std::filesystem::path& p) { + expect(lm.link_flags([](const std::filesystem::path& p) { return p.string(); }).empty()) << target; } -} +}}; // ─── Clang on the MSVC ABI: the toolset and SDK, said to the driver ─────── @@ -296,7 +294,7 @@ tc::Toolchain clang_msvc() { } } // namespace -TEST(LinkModel, ClangOnTheMsvcAbiNamesItsToolsetAndSdk) { +UTest clang_on_the_msvc_abi_names_its_toolset_and_sdk{"LinkModel/ClangOnTheMsvcAbiNamesItsToolsetAndSdk", [] { auto t = clang_msvc(); auto words = tc::resolve_link_model(t).msvc_driver_tokens(ident); const std::vector expected = { @@ -304,49 +302,49 @@ TEST(LinkModel, ClangOnTheMsvcAbiNamesItsToolsetAndSdk) { "-Xmicrosoft-windows-sdk-root", t.base.msvc.sdkRoot.string(), "-Xmicrosoft-windows-sdk-version", "10.0.26100.0", }; - EXPECT_EQ(words, expected) << "separate words: the driver rejects the `=` form"; + expect(eq(words, expected)) << "separate words: the driver rejects the `=` form"; // Not a C-library mode: the other token sets stay empty. auto lm = tc::resolve_link_model(t); - EXPECT_EQ(lm.mode, tc::CLibMode::None); - EXPECT_TRUE(lm.compile_tokens(ident).empty()); -} + expect(eq(lm.mode, tc::CLibMode::None)); + expect(lm.compile_tokens(ident).empty()); +}}; // mcpp#746. The compiler version travels with the toolset: two runner images // held different cl.exe builds under one toolset directory name, and a key // that saw only the directory served one image's std module to the other. -TEST(LinkModel, TheCompilerVersionIsSaidWithTheToolset) { +UTest the_compiler_version_is_said_with_the_toolset{"LinkModel/TheCompilerVersionIsSaidWithTheToolset", [] { auto t = clang_msvc(); t.base.msvc.compilerVersion = "19.44.35211"; auto words = tc::resolve_link_model(t).msvc_driver_tokens(ident); - ASSERT_FALSE(words.empty()); - EXPECT_EQ(words.back(), "-fms-compatibility-version=19.44.35211"); + expect(fatal(!words.empty())); + expect(eq(words.back(), "-fms-compatibility-version=19.44.35211")); // A second build of cl.exe under the same directory is a different word. auto other = t; other.base.msvc.compilerVersion = "19.44.35217"; - EXPECT_NE(tc::resolve_link_model(other).msvc_driver_tokens(ident), words); -} + expect(ne(tc::resolve_link_model(other).msvc_driver_tokens(ident), words)); +}}; -TEST(LinkModel, TheToolsetWithoutAnSdkOmitsTheSdkWords) { +UTest the_toolset_without_an_sdk_omits_the_sdk_words{"LinkModel/TheToolsetWithoutAnSdkOmitsTheSdkWords", [] { auto t = clang_msvc(); t.base.msvc.sdkRoot.clear(); t.base.msvc.sdkVersion.clear(); auto words = tc::resolve_link_model(t).msvc_driver_tokens(ident); - ASSERT_EQ(words.size(), 2u); - EXPECT_EQ(words[0], "-Xmicrosoft-visualc-tools-root"); -} + expect(fatal(eq(words.size(), 2u))); + expect(eq(words[0], "-Xmicrosoft-visualc-tools-root")); +}}; -TEST(LinkModel, NoOtherRowReceivesMsvcDriverWords) { +UTest no_other_row_receives_msvc_driver_words{"LinkModel/NoOtherRowReceivesMsvcDriverWords", [] { // cl.exe carries the toolset in its own path and envOverrides; MinGW is // not MSVC at all; a row with no toolset recorded says nothing. auto cl = clang_msvc(); cl.compiler = tc::CompilerId::MSVC; - EXPECT_TRUE(tc::resolve_link_model(cl).msvc_driver_tokens(ident).empty()); + expect(tc::resolve_link_model(cl).msvc_driver_tokens(ident).empty()); auto mingw = clang_msvc(); mingw.targetTriple = "x86_64-w64-mingw32"; - EXPECT_TRUE(tc::resolve_link_model(mingw).msvc_driver_tokens(ident).empty()); + expect(tc::resolve_link_model(mingw).msvc_driver_tokens(ident).empty()); auto unbound = clang_msvc(); unbound.base.msvc.toolsDir.clear(); - EXPECT_TRUE(tc::resolve_link_model(unbound).msvc_driver_tokens(ident).empty()); -} + expect(tc::resolve_link_model(unbound).msvc_driver_tokens(ident).empty()); +}}; diff --git a/tests/unit/test_loader_contract.cpp b/tests/unit/test_loader_contract.cpp index 4dcf84256..5e386aee3 100644 --- a/tests/unit/test_loader_contract.cpp +++ b/tests/unit/test_loader_contract.cpp @@ -1,6 +1,6 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.loader_contract; import mcpp.build.graph_shape; import mcpp.pack.host_requirements; @@ -12,38 +12,40 @@ import mcpp.runtime.binding; import mcpplibs.os.platform; import nlohmann.json; +using namespace mcpplibs::mtest; + namespace { // ─── the loader-tag contract ──────────────────────────────────────────────── -TEST(LoaderContract, ExecutablesNeedRpathLibrariesNeedRunpath) { +UTest executables_need_rpath_libraries_need_runpath{"LoaderContract/ExecutablesNeedRpathLibrariesNeedRunpath", [] { using namespace mcpp::build::loader; - EXPECT_EQ(required_tag(Form::Executable), RequiredTag::Rpath); - EXPECT_EQ(required_tag(Form::SharedLibrary), RequiredTag::Runpath); - EXPECT_EQ(required_tag(Form::NotElf), RequiredTag::NotApplicable); -} + expect(eq(required_tag(Form::Executable), RequiredTag::Rpath)); + expect(eq(required_tag(Form::SharedLibrary), RequiredTag::Runpath)); + expect(eq(required_tag(Form::NotElf), RequiredTag::NotApplicable)); +}}; // The two producers must not each decide the spelling. If one of them ever // grows its own string literal, this is the test that notices. -TEST(LoaderContract, BothProducersSpellTheSameRuleFromOneSource) { +UTest both_producers_spell_the_same_rule_from_one_source{"LoaderContract/BothProducersSpellTheSameRuleFromOneSource", [] { using namespace mcpp::build::loader; auto exeLink = link_flag(required_tag(Form::Executable)); auto exePatch = patchelf_flag(required_tag(Form::Executable)); - ASSERT_TRUE(exeLink.has_value()); - ASSERT_TRUE(exePatch.has_value()); - EXPECT_EQ(*exeLink, "-Wl,--disable-new-dtags"); - EXPECT_EQ(*exePatch, "--force-rpath"); + expect(fatal(exeLink.has_value())); + expect(fatal(exePatch.has_value())); + expect(eq(*exeLink, "-Wl,--disable-new-dtags")); + expect(eq(*exePatch, "--force-rpath")); // Libraries take the default in BOTH producers -- forcing DT_RPATH onto a // library pushes its search path into every lookup below it and breaks // eglInitialize (openxlings/xlings#593). - EXPECT_FALSE(link_flag(required_tag(Form::SharedLibrary)).has_value()); - EXPECT_FALSE(patchelf_flag(required_tag(Form::SharedLibrary)).has_value()); -} + expect(!(link_flag(required_tag(Form::SharedLibrary)).has_value())); + expect(!(patchelf_flag(required_tag(Form::SharedLibrary)).has_value())); +}}; // ─── graph shape ──────────────────────────────────────────────────────────── -TEST(GraphShape, UnlabelledOrUnknownGraphIsNeverPlain) { +UTest unlabelled_or_unknown_graph_is_never_plain{"GraphShape/UnlabelledOrUnknownGraphIsNeverPlain", [] { using namespace mcpp::build; auto dir = std::filesystem::temp_directory_path() / "mcpp_graph_shape_test"; @@ -59,10 +61,10 @@ TEST(GraphShape, UnlabelledOrUnknownGraphIsNeverPlain) { return p; }; - EXPECT_TRUE(is_plain_build_graph( + expect(is_plain_build_graph( write("normal.ninja", "# banner\n# mcpp:graph=normal;schedule=none;accel=default;dist=none\nrule x\n"))); - EXPECT_FALSE(is_plain_build_graph( - write("test.ninja", "# banner\n# mcpp:graph=test;schedule=none;accel=default;dist=none\nrule x\n"))); + expect(!(is_plain_build_graph( + write("test.ninja", "# banner\n# mcpp:graph=test;schedule=none;accel=default;dist=none\nrule x\n")))); // A plain-shaped graph that `mcpp pack --format ` wrote (2026.9.11.1+): // it carries an artifact edge consuming a staged tree, which a plain build @@ -70,42 +72,42 @@ TEST(GraphShape, UnlabelledOrUnknownGraphIsNeverPlain) { // `header_line`, which is the point of this copy -- a reader of build.ninja // sees the text, and a field added to the producer without being added to // the reader would still pass a test that only compared the two. - EXPECT_FALSE(is_plain_build_graph( - write("dist.ninja", "# mcpp:graph=normal;schedule=none;accel=default;dist=appimage\n"))); + expect(!(is_plain_build_graph( + write("dist.ninja", "# mcpp:graph=normal;schedule=none;accel=default;dist=appimage\n")))); // A plain-shaped graph an `--accel` / `--no-accel` build wrote (2026.9.5.3+): // the variant a flag chose is not the variant a plain build produces. - EXPECT_FALSE(is_plain_build_graph( - write("override.ninja", "# mcpp:graph=normal;schedule=none;accel=override\n"))); + expect(!(is_plain_build_graph( + write("override.ninja", "# mcpp:graph=normal;schedule=none;accel=override\n")))); // A graph from 2026.9.5.2 and earlier: shape and schedule, no selection // field. Not known to be the manifest's variant, so a miss, not a guess. - EXPECT_FALSE(is_plain_build_graph( - write("no-selection.ninja", "# banner\n# mcpp:graph=normal\nrule x\n"))); + expect(!(is_plain_build_graph( + write("no-selection.ninja", "# banner\n# mcpp:graph=normal\nrule x\n")))); // A graph from 2026.9.10.2: shape, schedule and selection, no distribution // field. Same rule one field later. This assertion is what caught the // second copy of these lines when the field was added -- the line above it // was the CURRENT spelling in one file and became the LEGACY spelling in // both, and only a test that spells it out could say so. - EXPECT_FALSE(is_plain_build_graph( - write("no-dist.ninja", "# banner\n# mcpp:graph=normal;schedule=none;accel=default\nrule x\n"))); + expect(!(is_plain_build_graph( + write("no-dist.ninja", "# banner\n# mcpp:graph=normal;schedule=none;accel=default\nrule x\n")))); // A build.ninja from before the marker existed. It MUST read as a miss: // treating it as plain is precisely the replay #407 is about. - EXPECT_FALSE(is_plain_build_graph( - write("legacy.ninja", "# banner\nninja_required_version = 1.11\n"))); + expect(!(is_plain_build_graph( + write("legacy.ninja", "# banner\nninja_required_version = 1.11\n")))); // A shape this binary does not know. An older mcpp meeting a newer graph // must fall back, not guess. - EXPECT_FALSE(is_plain_build_graph( - write("future.ninja", "# mcpp:graph=coverage\n"))); + expect(!(is_plain_build_graph( + write("future.ninja", "# mcpp:graph=coverage\n")))); // Missing file. - EXPECT_FALSE(is_plain_build_graph(dir / "absent.ninja")); -} + expect(!is_plain_build_graph(dir / "absent.ninja")); +}}; -TEST(GraphShape, HeaderAndReaderAgree) { +UTest header_and_reader_agree{"GraphShape/HeaderAndReaderAgree", [] { using namespace mcpp::build; auto dir = std::filesystem::temp_directory_path() / "mcpp_graph_shape_rt"; std::filesystem::create_directories(dir); @@ -122,9 +124,9 @@ TEST(GraphShape, HeaderAndReaderAgree) { { std::ofstream out(p, std::ios::trunc); out << header_line(shape, sched) << "\n"; } auto read = read_shape(p); - ASSERT_TRUE(read.has_value()); - EXPECT_EQ(*read, shape); - EXPECT_EQ(read_schedule(p), sched); + expect(fatal(read.has_value())); + expect(eq(*read, shape)); + expect(eq(read_schedule(p), sched)); } } @@ -137,11 +139,11 @@ TEST(GraphShape, HeaderAndReaderAgree) { auto p = dir / "build.ninja"; { std::ofstream out(p, std::ios::trunc); out << "# mcpp:graph=normal\n"; } auto read = read_shape(p); - ASSERT_TRUE(read.has_value()); - EXPECT_EQ(*read, GraphShape::Normal); - EXPECT_TRUE(read_schedule(p).empty()); + expect(fatal(read.has_value())); + expect(eq(*read, GraphShape::Normal)); + expect(read_schedule(p).empty()); } -} +}}; // ─── host requirements: ONE derivation, two projections ───────────────────── @@ -151,7 +153,7 @@ mcpp::manifest::RuntimeConfig runtime_with(std::vector capabilities return rc; } -TEST(HostRequirements, OnlyRunPhaseCapabilitiesCount) { +UTest only_run_phase_capabilities_count{"HostRequirements/OnlyRunPhaseCapabilitiesCount", [] { mcpp::manifest::RuntimeConfig rc; rc.requirements.push_back({.kind = "capability", .value = "opengl.glx.driver", .phase = "run"}); @@ -164,59 +166,58 @@ TEST(HostRequirements, OnlyRunPhaseCapabilitiesCount) { .phase = "run"}); auto reqs = mcpp::pack::host_requirements_of(rc); - ASSERT_EQ(reqs.size(), 1u); - EXPECT_EQ(reqs[0].capability, "opengl.glx.driver"); -} + expect(fatal(eq(reqs.size(), 1u))); + expect(eq(reqs[0].capability, "opengl.glx.driver")); +}}; // The legacy vector carries the same meaning. A package that has not migrated // must not silently produce an empty list -- an empty HOST-REQUIREMENTS is a // CLAIM that nothing is needed. -TEST(HostRequirements, LegacyCapabilitiesVectorIsStillRead) { +UTest legacy_capabilities_vector_is_still_read{"HostRequirements/LegacyCapabilitiesVectorIsStillRead", [] { auto reqs = mcpp::pack::host_requirements_of( runtime_with({"vulkan.icd", "opengl.egl.driver"})); - ASSERT_EQ(reqs.size(), 2u); - EXPECT_EQ(reqs[0].capability, "opengl.egl.driver"); // sorted - EXPECT_EQ(reqs[1].capability, "vulkan.icd"); -} + expect(fatal(eq(reqs.size(), 2u))); + expect(eq(reqs[0].capability, "opengl.egl.driver")); // sorted + expect(eq(reqs[1].capability, "vulkan.icd")); +}}; -TEST(HostRequirements, DuplicatesAcrossBothFormsCollapse) { +UTest duplicates_across_both_forms_collapse{"HostRequirements/DuplicatesAcrossBothFormsCollapse", [] { mcpp::manifest::RuntimeConfig rc; rc.requirements.push_back({.kind = "capability", .value = "opengl.glx.driver", .phase = "run"}); rc.capabilities.push_back("opengl.glx.driver"); - EXPECT_EQ(mcpp::pack::host_requirements_of(rc).size(), 1u); -} + expect(eq(mcpp::pack::host_requirements_of(rc).size(), 1u)); +}}; // The mechanism is DECLARED, never inferred. mcpp inferring it from the // capability name would be provider-specific knowledge in mcpp's source -- // gated by test_runtime_contract, and wrong on its merits: the mechanism is // the provider's property and changes without mcpp. -TEST(HostRequirements, DiscoveryIsCarriedNotGuessed) { +UTest discovery_is_carried_not_guessed{"HostRequirements/DiscoveryIsCarriedNotGuessed", [] { mcpp::manifest::RuntimeConfig rc; rc.requirements.push_back({.kind = "capability", .value = "opengl.egl.driver", .phase = "run", .discovery = "json-dir"}); auto reqs = mcpp::pack::host_requirements_of(rc); - ASSERT_EQ(reqs.size(), 1u); - EXPECT_EQ(reqs[0].discovery, "json-dir"); + expect(fatal(eq(reqs.size(), 1u))); + expect(eq(reqs[0].discovery, "json-dir")); // Undeclared stays empty and renders as `unknown` -- saying "we do not // know how this is found" is information; guessing would be a claim. auto legacy = mcpp::pack::host_requirements_of( runtime_with({"opengl.glx.driver"})); - ASSERT_EQ(legacy.size(), 1u); - EXPECT_TRUE(legacy[0].discovery.empty()); - EXPECT_NE(mcpp::pack::render(legacy).find("discovery=unknown"), - std::string::npos); -} + expect(fatal(eq(legacy.size(), 1u))); + expect(legacy[0].discovery.empty()); + expect(ne(mcpp::pack::render(legacy).find("discovery=unknown"), std::string::npos)); +}}; -TEST(HostRequirements, RenderIsGreppable) { +UTest render_is_greppable{"HostRequirements/RenderIsGreppable", [] { mcpp::manifest::RuntimeConfig rc; rc.requirements.push_back({.kind = "capability", .value = "opengl.glx.driver", .phase = "run", .discovery = "rpath-of-dispatch"}); auto text = mcpp::pack::render(mcpp::pack::host_requirements_of(rc)); - EXPECT_NE(text.find("capability=opengl.glx.driver"), std::string::npos); - EXPECT_NE(text.find("discovery=rpath-of-dispatch"), std::string::npos); -} + expect(ne(text.find("capability=opengl.glx.driver"), std::string::npos)); + expect(ne(text.find("discovery=rpath-of-dispatch"), std::string::npos)); +}}; // ─── declared runtime artifact -> identity verdict ────────────────────────── // @@ -224,7 +225,7 @@ TEST(HostRequirements, RenderIsGreppable) { // symlink on disk still resolved into 0.1.1. Detectable as a pure path fact — // no knowledge of what the artifact does. -TEST(ArtifactIdentity, FourValuedAndSymlinkAware) { +UTest four_valued_and_symlink_aware{"ArtifactIdentity/FourValuedAndSymlinkAware", [] { using namespace mcpp::build::runtime_validation; namespace fs = std::filesystem; @@ -245,31 +246,31 @@ TEST(ArtifactIdentity, FourValuedAndSymlinkAware) { a.provenance = "xim:vendor@0.1.2"; // Nothing declared / nothing there. - EXPECT_EQ(artifact_identity_verdict(a), ArtifactVerdict::Missing); + expect(eq(artifact_identity_verdict(a), ArtifactVerdict::Missing)); a.path = root / "absent.so"; - EXPECT_EQ(artifact_identity_verdict(a), ArtifactVerdict::Missing); + expect(eq(artifact_identity_verdict(a), ArtifactVerdict::Missing)); // Declared version matches the resolved payload. a.path = root / "store" / "0.1.2" / "lib" / "libvendor.so"; - EXPECT_EQ(artifact_identity_verdict(a), ArtifactVerdict::Ok); + expect(eq(artifact_identity_verdict(a), ArtifactVerdict::Ok)); // THE case. A symlink that still points into the previous payload: reading // the declared path alone would confirm the promise against itself. fs::create_directory_symlink(root / "store" / "0.1.1", root / "current", ec); if (!ec) { a.path = root / "current" / "lib" / "libvendor.so"; - EXPECT_EQ(artifact_identity_verdict(a), ArtifactVerdict::Mismatch); + expect(eq(artifact_identity_verdict(a), ArtifactVerdict::Mismatch)); } // No version to check against is UNVERIFIED, never Ok — "not checked" and // "checked and fine" must not look the same. a.path = root / "store" / "0.1.2" / "lib" / "libvendor.so"; a.provenance = "xim:vendor"; - EXPECT_EQ(artifact_identity_verdict(a), ArtifactVerdict::Unverified); -} + expect(eq(artifact_identity_verdict(a), ArtifactVerdict::Unverified)); +}}; // A component match, not a substring: 0.1.1 must not satisfy 0.1.11. -TEST(ArtifactIdentity, VersionMatchIsAPathComponent) { +UTest version_match_is_a_path_component{"ArtifactIdentity/VersionMatchIsAPathComponent", [] { using namespace mcpp::build::runtime_validation; namespace fs = std::filesystem; auto root = fs::temp_directory_path() / "mcpp_artifact_identity_sub"; @@ -283,8 +284,8 @@ TEST(ArtifactIdentity, VersionMatchIsAPathComponent) { mcpp::manifest::RuntimeArtifact a; a.path = root / "0.1.11" / "lib.so"; a.provenance = "xim:vendor@0.1.1"; - EXPECT_EQ(artifact_identity_verdict(a), ArtifactVerdict::Mismatch); -} + expect(eq(artifact_identity_verdict(a), ArtifactVerdict::Mismatch)); +}}; // ─── a pass with nothing to say does not erase what a pass measured ───────── // @@ -302,10 +303,10 @@ TEST(ArtifactIdentity, VersionMatchIsAPathComponent) { // // Stated here because no end-to-end shape reaches it: it needs two drives over // one output directory where the SECOND one is the one that does not apply. -TEST(DlopenSurfaceRecord, ANonAnswerRepublishesTheAnswerAlreadyOnFile) { +UTest a_non_answer_republishes_the_answer_already_on_file{"DlopenSurfaceRecord/ANonAnswerRepublishesTheAnswerAlreadyOnFile", [] { namespace fs = std::filesystem; if constexpr (!mcpplibs::os::is_linux) { - SUCCEED() << "the record is ELF-shaped and this host links no ELF"; + /* passes */; return; } @@ -337,12 +338,12 @@ TEST(DlopenSurfaceRecord, ANonAnswerRepublishesTheAnswerAlreadyOnFile) { regenerate_resolution(); mcpp::build::runtime_validation::check_dlopen_surface(plan); auto stored = read_json(sidecar); - ASSERT_TRUE(stored.is_object()) << "the sidecar was not written"; - ASSERT_TRUE(stored.contains("dlopen_surface")); - EXPECT_FALSE(stored["dlopen_surface"].value("reason", "").empty()) + expect(fatal(stored.is_object())) << "the sidecar was not written"; + expect(fatal(stored.contains("dlopen_surface"))); + expect(!(stored["dlopen_surface"].value("reason", "").empty())) << "a published non-answer carries the reason it did not apply"; - EXPECT_FALSE(read_json(root / "resolution.json")["runtime"]["dlopen_surface"] - .value("reason", "").empty()) + expect(!(read_json(root / "resolution.json")["runtime"]["dlopen_surface"] + .value("reason", "").empty())) << "the documented place to look carries it too"; // A pass that DID apply now records a reading, under the key already on @@ -359,14 +360,14 @@ TEST(DlopenSurfaceRecord, ANonAnswerRepublishesTheAnswerAlreadyOnFile) { mcpp::build::runtime_validation::check_dlopen_surface(plan); auto after = read_json(sidecar)["dlopen_surface"]; - EXPECT_EQ(after.value("members", 0), 7) + expect(eq(after.value("members", 0), 7)) << "a non-answer replaced a reading taken under the same key"; - EXPECT_FALSE(after.contains("reason")); + expect(!after.contains("reason")); auto republished = read_json(root / "resolution.json")["runtime"]["dlopen_surface"]; - EXPECT_EQ(republished.value("members", 0), 7) + expect(eq(republished.value("members", 0), 7)) << "the reading survived in the sidecar but not where it is documented"; - EXPECT_EQ(republished.value("walked", 0), 7); -} + expect(eq(republished.value("walked", 0), 7)); +}}; } // namespace diff --git a/tests/unit/test_luban_root.cpp b/tests/unit/test_luban_root.cpp index 0a48b8250..8dbc26aa0 100644 --- a/tests/unit/test_luban_root.cpp +++ b/tests/unit/test_luban_root.cpp @@ -4,13 +4,15 @@ // root record names, which `[xlings] upstream = ["/xlings"]` means for a // system made on a host (whose home is not at /xlings). -#include import std; +import mcpplibs.mtest; import mcpp.provider; import mcpp.provider.select; import mcpp.fallback.probe_sysroot; +using namespace mcpplibs::mtest; + namespace fs = std::filesystem; namespace { @@ -18,7 +20,7 @@ namespace { struct Tree { fs::path dir; Tree() { - dir = fs::temp_directory_path() / std::format("mcpp-luban-root-{}-{}", ::testing::UnitTest::GetInstance()->random_seed(), + dir = fs::temp_directory_path() / std::format("mcpp-luban-root-{}-{}", mcpplibs::mtest::seed(), std::chrono::steady_clock::now().time_since_epoch().count()); fs::create_directories(dir); } @@ -31,36 +33,36 @@ struct Tree { } // namespace -TEST(LubanRoot, ALubanSystemsRootIsDeclared) { +UTest a_luban_systems_root_is_declared{"LubanRoot/ALubanSystemsRootIsDeclared", [] { Tree t; - EXPECT_FALSE(mcpp::provider::layout().declared_system_root(t.dir)); + expect(!(mcpp::provider::layout().declared_system_root(t.dir))); t.write("etc/os-release", "NAME=\"Luban\"\nID=luban\nVERSION_ID=2026.10.22.1\n"); - EXPECT_FALSE(mcpp::provider::layout().declared_system_root(t.dir)) << "its os-release alone is not enough"; + expect(!(mcpp::provider::layout().declared_system_root(t.dir))) << "its os-release alone is not enough"; t.write("usr/lib/xlings-abi/README", ""); - EXPECT_TRUE(mcpp::provider::layout().declared_system_root(t.dir)); + expect(bool(mcpp::provider::layout().declared_system_root(t.dir))); t.write("etc/os-release", "ID=\"debian\"\n"); - EXPECT_FALSE(mcpp::provider::layout().declared_system_root(t.dir)); -} + expect(!(mcpp::provider::layout().declared_system_root(t.dir))); +}}; -TEST(LubanRoot, ItsSysrootSlashIsNotForeign) { +UTest its_sysroot_slash_is_not_foreign{"LubanRoot/ItsSysrootSlashIsNotForeign", [] { Tree t; const fs::path registry = "/home/u/.mcpp/registry", project = "/home/u/work/demo"; - EXPECT_TRUE(mcpp::fallback::sysroot_is_foreign("/", registry, project, t.dir)) << "anywhere else, / is nothing declared"; + expect(mcpp::fallback::sysroot_is_foreign("/", registry, project, t.dir)) << "anywhere else, / is nothing declared"; t.write("etc/os-release", "ID=luban\n"); t.write("usr/lib/xlings-abi/README", ""); - EXPECT_FALSE(mcpp::fallback::sysroot_is_foreign("/", registry, project, t.dir)); - EXPECT_TRUE(mcpp::fallback::sysroot_is_foreign("/opt/other/sysroot", registry, project, t.dir)) << "only its own root"; -} + expect(!mcpp::fallback::sysroot_is_foreign("/", registry, project, t.dir)); + expect(mcpp::fallback::sysroot_is_foreign("/opt/other/sysroot", registry, project, t.dir)) << "only its own root"; +}}; -TEST(LubanRoot, ItsStoreIsTheHomeItsRootRecordNames) { +UTest its_store_is_the_home_its_root_record_names{"LubanRoot/ItsStoreIsTheHomeItsRootRecordNames", [] { Tree t; t.write("etc/os-release", "ID=luban\n"); t.write("usr/lib/xlings-abi/README", ""); - EXPECT_TRUE(mcpp::provider::layout().system_home(t.dir).empty()) << "no record"; + expect(mcpp::provider::layout().system_home(t.dir).empty()) << "no record"; const auto home = t.dir / "host-home"; fs::create_directories(home / "subos" / "box" / "root" / "usr"); t.write("etc/xlings/usr", (home / "subos" / "box" / "root" / "usr").string() + "\n"); - EXPECT_EQ(mcpp::provider::layout().system_home(t.dir), home.lexically_normal()); + expect(eq(mcpp::provider::layout().system_home(t.dir), home.lexically_normal())); t.write("etc/xlings/usr", "/somewhere/else/usr\n"); - EXPECT_TRUE(mcpp::provider::layout().system_home(t.dir).empty()) << "a record of another shape"; -} + expect(mcpp::provider::layout().system_home(t.dir).empty()) << "a record of another shape"; +}}; diff --git a/tests/unit/test_main_detection.cpp b/tests/unit/test_main_detection.cpp index 9603c2446..5110d696c 100644 --- a/tests/unit/test_main_detection.cpp +++ b/tests/unit/test_main_detection.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.plan; +using namespace mcpplibs::mtest; + using namespace mcpp::build; namespace { @@ -27,53 +29,53 @@ bool defines_main(std::string_view content, std::string_view tag) { } // namespace -TEST(MainDetection, RealMainIsDetected) { - EXPECT_TRUE(defines_main("import std;\nint main() { return 0; }\n", "real")); -} +UTest real_main_is_detected{"MainDetection/RealMainIsDetected", [] { + expect(bool(defines_main("import std;\nint main() { return 0; }\n", "real"))); +}}; -TEST(MainDetection, RealMainWithArgsIsDetected) { - EXPECT_TRUE(defines_main( - "int main(int argc, char** argv) { (void)argc; (void)argv; return 0; }\n", "args")); -} +UTest real_main_with_args_is_detected{"MainDetection/RealMainWithArgsIsDetected", [] { + expect(bool(defines_main( + "int main(int argc, char** argv) { (void)argc; (void)argv; return 0; }\n", "args"))); +}}; -TEST(MainDetection, AutoMainIsDetected) { - EXPECT_TRUE(defines_main("auto main() -> int { return 0; }\n", "automain")); -} +UTest auto_main_is_detected{"MainDetection/AutoMainIsDetected", [] { + expect(bool(defines_main("auto main() -> int { return 0; }\n", "automain"))); +}}; // The regression: test fixtures embed `"int main() {...}"` as a STRING — that // must NOT count as the test binary defining main (it doesn't). A false positive // chose archive linking → gtest_main.o not pulled by MSVC lld-link → LNK1561. -TEST(MainDetection, MainInsideStringLiteralIsIgnored) { - EXPECT_FALSE(defines_main( +UTest main_inside_string_literal_is_ignored{"MainDetection/MainInsideStringLiteralIsIgnored", [] { + expect(!(defines_main( "#include \n" "TEST(M, x) {\n" " auto src = \"int main() { return 0; }\\n\";\n" " EXPECT_FALSE(src.empty());\n" - "}\n", "strlit")); -} + "}\n", "strlit"))); +}}; -TEST(MainDetection, MainInsideRawStringIsIgnored) { - EXPECT_FALSE(defines_main( +UTest main_inside_raw_string_is_ignored{"MainDetection/MainInsideRawStringIsIgnored", [] { + expect(!(defines_main( "#include \n" "TEST(M, x) {\n" " auto src = R\"(\nint main() { return 0; }\n)\";\n" " EXPECT_FALSE(src.empty());\n" - "}\n", "rawstr")); -} + "}\n", "rawstr"))); +}}; -TEST(MainDetection, MainInsideCommentIsIgnored) { - EXPECT_FALSE(defines_main( +UTest main_inside_comment_is_ignored{"MainDetection/MainInsideCommentIsIgnored", [] { + expect(!(defines_main( "// int main() { return 0; }\n" "#include \n" - "TEST(M, x) { EXPECT_TRUE(true); }\n", "comment")); -} + "TEST(M, x) { EXPECT_TRUE(true); }\n", "comment"))); +}}; -TEST(MainDetection, NoMainGtestStyleIsFalse) { - EXPECT_FALSE(defines_main( +UTest no_main_gtest_style_is_false{"MainDetection/NoMainGtestStyleIsFalse", [] { + expect(!(defines_main( "#include \n" - "TEST(M, x) { EXPECT_EQ(1, 1); }\n", "nomain")); -} + "TEST(M, x) { EXPECT_EQ(1, 1); }\n", "nomain"))); +}}; -TEST(MainDetection, SimilarIdentifierIsNotMain) { - EXPECT_FALSE(defines_main("int mainHelper() { return 0; }\n", "helper")); -} +UTest similar_identifier_is_not_main{"MainDetection/SimilarIdentifierIsNotMain", [] { + expect(!(defines_main("int mainHelper() { return 0; }\n", "helper"))); +}}; diff --git a/tests/unit/test_mangle.cpp b/tests/unit/test_mangle.cpp index 5134529f5..f83ae1473 100644 --- a/tests/unit/test_mangle.cpp +++ b/tests/unit/test_mangle.cpp @@ -1,17 +1,19 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pm.mangle; +using namespace mcpplibs::mtest; + using namespace mcpp::pm; -TEST(Mangle, NameFormat) { - EXPECT_EQ(mangle_name("json", "1.2.3"), "json__v1_2_3__mcpp"); - EXPECT_EQ(mangle_name("mcpplibs.cmdline","2.0"),"mcpplibs.cmdline__v2_0__mcpp"); - EXPECT_EQ(mangle_name("a", "0"), "a__v0__mcpp"); -} +UTest name_format{"Mangle/NameFormat", [] { + expect(eq(mangle_name("json", "1.2.3"), "json__v1_2_3__mcpp")); + expect(eq(mangle_name("mcpplibs.cmdline","2.0"), "mcpplibs.cmdline__v2_0__mcpp")); + expect(eq(mangle_name("a", "0"), "a__v0__mcpp")); +}}; -TEST(Mangle, DiscoversAuthoredModuleRootsWithoutPackageNameAssumptions) { +UTest discovers_authored_module_roots_without_package_name_assumptions{"Mangle/DiscoversAuthoredModuleRootsWithoutPackageNameAssumptions", [] { auto roots = declared_module_roots( "module;\n" "export module mcpplibs.cmdline;\n" @@ -19,75 +21,64 @@ TEST(Mangle, DiscoversAuthoredModuleRootsWithoutPackageNameAssumptions) { "module mcpplibs.cmdline:impl;\n" "import unrelated;\n" "export module vendor.extra;\n"); - EXPECT_EQ(roots, (std::vector{ - "mcpplibs.cmdline", "vendor.extra"})); -} + expect(eq(roots, (std::vector{ + "mcpplibs.cmdline", "vendor.extra"}))); +}}; -TEST(Mangle, RewriteEmpty) { +UTest rewrite_empty{"Mangle/RewriteEmpty", [] { std::map table; - EXPECT_EQ(rewrite_module_decls("", table), ""); - EXPECT_EQ(rewrite_module_decls("export module foo;\n", table), - "export module foo;\n") + expect(eq(rewrite_module_decls("", table), "")); + expect(eq(rewrite_module_decls("export module foo;\n", table), "export module foo;\n")) << "empty rename table → no change"; -} +}}; -TEST(Mangle, RewriteModuleDecl) { +UTest rewrite_module_decl{"Mangle/RewriteModuleDecl", [] { std::map table { {"json", "json__v2_0_0__mcpp"}, }; - EXPECT_EQ(rewrite_module_decls("export module json;\n", table), - "export module json__v2_0_0__mcpp;\n"); - EXPECT_EQ(rewrite_module_decls("module json;\n", table), - "module json__v2_0_0__mcpp;\n"); -} + expect(eq(rewrite_module_decls("export module json;\n", table), "export module json__v2_0_0__mcpp;\n")); + expect(eq(rewrite_module_decls("module json;\n", table), "module json__v2_0_0__mcpp;\n")); +}}; -TEST(Mangle, RewritePartitionDecl) { +UTest rewrite_partition_decl{"Mangle/RewritePartitionDecl", [] { std::map table { {"json", "json__v2_0_0__mcpp"}, }; - EXPECT_EQ(rewrite_module_decls("export module json:utils;\n", table), - "export module json__v2_0_0__mcpp:utils;\n"); - EXPECT_EQ(rewrite_module_decls("module json:impl;\n", table), - "module json__v2_0_0__mcpp:impl;\n"); -} + expect(eq(rewrite_module_decls("export module json:utils;\n", table), "export module json__v2_0_0__mcpp:utils;\n")); + expect(eq(rewrite_module_decls("module json:impl;\n", table), "module json__v2_0_0__mcpp:impl;\n")); +}}; -TEST(Mangle, RewriteImports) { +UTest rewrite_imports{"Mangle/RewriteImports", [] { std::map table { {"json", "json__v2_0_0__mcpp"}, }; - EXPECT_EQ(rewrite_module_decls("import json;\n", table), - "import json__v2_0_0__mcpp;\n"); - EXPECT_EQ(rewrite_module_decls("export import json;\n", table), - "export import json__v2_0_0__mcpp;\n"); - EXPECT_EQ(rewrite_module_decls("import json:utils;\n", table), - "import json__v2_0_0__mcpp:utils;\n"); -} + expect(eq(rewrite_module_decls("import json;\n", table), "import json__v2_0_0__mcpp;\n")); + expect(eq(rewrite_module_decls("export import json;\n", table), "export import json__v2_0_0__mcpp;\n")); + expect(eq(rewrite_module_decls("import json:utils;\n", table), "import json__v2_0_0__mcpp:utils;\n")); +}}; -TEST(Mangle, KeepBarePartitionImport) { +UTest keep_bare_partition_import{"Mangle/KeepBarePartitionImport", [] { std::map table { {"json", "json__v2_0_0__mcpp"}, }; // `import :P;` refers to the enclosing module's partition — not a // named module; must NOT be rewritten. - EXPECT_EQ(rewrite_module_decls("import :utils;\n", table), - "import :utils;\n"); - EXPECT_EQ(rewrite_module_decls("module ;\n", table), - "module ;\n") + expect(eq(rewrite_module_decls("import :utils;\n", table), "import :utils;\n")); + expect(eq(rewrite_module_decls("module ;\n", table), "module ;\n")) << "global module fragment opener has no name"; -} +}}; -TEST(Mangle, KeepNonMatching) { +UTest keep_non_matching{"Mangle/KeepNonMatching", [] { std::map table { {"json", "json__v2_0_0__mcpp"}, }; // Other modules in the same file shouldn't be touched. - EXPECT_EQ(rewrite_module_decls("import std;\n", table), "import std;\n"); - EXPECT_EQ(rewrite_module_decls("import other;\n", table), "import other;\n"); - EXPECT_EQ(rewrite_module_decls("export module yaml;\n", table), - "export module yaml;\n"); -} + expect(eq(rewrite_module_decls("import std;\n", table), "import std;\n")); + expect(eq(rewrite_module_decls("import other;\n", table), "import other;\n")); + expect(eq(rewrite_module_decls("export module yaml;\n", table), "export module yaml;\n")); +}}; -TEST(Mangle, MultipleLines) { +UTest multiple_lines{"Mangle/MultipleLines", [] { std::map table { {"json", "json__v2_0_0__mcpp"}, }; @@ -107,33 +98,28 @@ TEST(Mangle, MultipleLines) { "import std;\n" "import json__v2_0_0__mcpp:utils;\n" "export int answer() { return 42; }\n"; - EXPECT_EQ(rewrite_module_decls(in, table), expected); -} + expect(eq(rewrite_module_decls(in, table), expected)); +}}; -TEST(Mangle, LeadingWhitespace) { +UTest leading_whitespace{"Mangle/LeadingWhitespace", [] { std::map table { {"json", "json__v2_0_0__mcpp"}, }; - EXPECT_EQ(rewrite_module_decls(" export module json;\n", table), - " export module json__v2_0_0__mcpp;\n"); - EXPECT_EQ(rewrite_module_decls("\timport json;\n", table), - "\timport json__v2_0_0__mcpp;\n"); -} + expect(eq(rewrite_module_decls(" export module json;\n", table), " export module json__v2_0_0__mcpp;\n")); + expect(eq(rewrite_module_decls("\timport json;\n", table), "\timport json__v2_0_0__mcpp;\n")); +}}; -TEST(Mangle, NoTrailingNewline) { +UTest no_trailing_newline{"Mangle/NoTrailingNewline", [] { std::map table { {"json", "json__v2_0_0__mcpp"}, }; - EXPECT_EQ(rewrite_module_decls("export module json;", table), - "export module json__v2_0_0__mcpp;"); -} + expect(eq(rewrite_module_decls("export module json;", table), "export module json__v2_0_0__mcpp;")); +}}; -TEST(Mangle, DottedNames) { +UTest dotted_names{"Mangle/DottedNames", [] { std::map table { {"mcpplibs.cmdline", "mcpplibs.cmdline__v2_0_0__mcpp"}, }; - EXPECT_EQ(rewrite_module_decls("export module mcpplibs.cmdline;\n", table), - "export module mcpplibs.cmdline__v2_0_0__mcpp;\n"); - EXPECT_EQ(rewrite_module_decls("import mcpplibs.cmdline;\n", table), - "import mcpplibs.cmdline__v2_0_0__mcpp;\n"); -} + expect(eq(rewrite_module_decls("export module mcpplibs.cmdline;\n", table), "export module mcpplibs.cmdline__v2_0_0__mcpp;\n")); + expect(eq(rewrite_module_decls("import mcpplibs.cmdline;\n", table), "import mcpplibs.cmdline__v2_0_0__mcpp;\n")); +}}; diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 07a2b4394..d8da0c35e 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -1,34 +1,36 @@ -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.targetside; import mcpp.libs.toml; import mcpp.pm.dep_spec; import mcpplibs.os.platform; import mcpp.toolchain.platform; + +using namespace mcpplibs::mtest; import mcpp.build.prepare; // merge_conditional_config: a row's statement replaces -TEST(Manifest, PeAutoExportDefaultsOnAndAcceptsAnExplicitOptOut) { +UTest pe_auto_export_defaults_on_and_accepts_an_explicit_opt_out{"Manifest/PeAutoExportDefaultsOnAndAcceptsAnExplicitOptOut", [] { for (auto const& declaration : {"", "windows_auto_export = true", "windows_auto_export = false"}) { auto m = mcpp::manifest::parse_string(std::format( "[package]\nname = \"dll\"\nversion = \"0.1.0\"\n" "[targets.dll]\nkind = \"shared\"\n{}\n", declaration)); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets.front().windows_auto_export(), std::string_view(declaration) != "windows_auto_export = false"); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->targets.size(), 1u))); + expect(eq(m->targets.front().windows_auto_export(), std::string_view(declaration) != "windows_auto_export = false")); // An omitted key stays distinguishable from a stated `true`. - EXPECT_EQ(m->targets.front().windowsAutoExportDeclared, *declaration != '\0'); - EXPECT_TRUE(m->schemaWarnings.empty()); + expect(eq(m->targets.front().windowsAutoExportDeclared, *declaration != '\0')); + expect(m->schemaWarnings.empty()); } auto bad = mcpp::manifest::parse_string( "[package]\nname = \"dll\"\nversion = \"0.1.0\"\n" "[targets.dll]\nkind = \"shared\"\nwindows_auto_export = \"false\"\n"); - ASSERT_FALSE(bad.has_value()); - EXPECT_NE(bad.error().format().find("windows_auto_export must be a boolean"), std::string::npos); -} + expect(fatal(!bad.has_value())); + expect(ne(bad.error().format().find("windows_auto_export must be a boolean"), std::string::npos)); +}}; -TEST(SynthesizeFromXpkgLua, PeAutoExportHasTheSameDefaultAndBooleanContract) { +UTest pe_auto_export_has_the_same_default_and_boolean_contract{"SynthesizeFromXpkgLua/PeAutoExportHasTheSameDefaultAndBooleanContract", [] { for (auto const& declaration : {"", "windows_auto_export = true,", "windows_auto_export = false,"}) { auto m = mcpp::manifest::synthesize_from_xpkg_lua(std::format(R"( package = {{ @@ -37,10 +39,10 @@ package = {{ mcpp = {{ sources = {{ "*/api.cpp" }}, targets = {{ ["dll"] = {{ kind = "shared", {} }} }} }}, }} )", declaration), "dll", "0.1.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets.front().windows_auto_export(), std::string_view(declaration) != "windows_auto_export = false,"); - EXPECT_EQ(m->targets.front().windowsAutoExportDeclared, *declaration != '\0'); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->targets.size(), 1u))); + expect(eq(m->targets.front().windows_auto_export(), std::string_view(declaration) != "windows_auto_export = false,")); + expect(eq(m->targets.front().windowsAutoExportDeclared, *declaration != '\0')); } auto bad = mcpp::manifest::synthesize_from_xpkg_lua(R"( package = { @@ -49,64 +51,64 @@ package = { mcpp = { sources = { "*/api.cpp" }, targets = { ["dll"] = { kind = "shared", windows_auto_export = "false" } } }, } )", "dll", "0.1.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_FALSE(bad.has_value()); - EXPECT_NE(bad.error().format().find("windows_auto_export must be a boolean"), std::string::npos); -} + expect(fatal(!bad.has_value())); + expect(ne(bad.error().format().find("windows_auto_export must be a boolean"), std::string::npos)); +}}; -TEST(Manifest, CppFlyStandard) { +UTest cpp_fly_standard{"Manifest/CppFlyStandard", [] { // standard = "c++fly": latest level + all experimental gates (design // 2026-07-14-std-features-experimental-gate-design.md §5.1). auto cfg = mcpp::manifest::normalize_cpp_standard("c++fly"); - ASSERT_TRUE(cfg.has_value()); - EXPECT_EQ(cfg->canonical, "c++fly"); - EXPECT_EQ(cfg->level, 1000); // above c++latest's 999 - EXPECT_TRUE(cfg->experimental); - EXPECT_FALSE(mcpp::manifest::normalize_cpp_standard("c++latest")->experimental); - EXPECT_FALSE(mcpp::manifest::normalize_cpp_standard("c++26")->experimental); + expect(fatal(cfg.has_value())); + expect(eq(cfg->canonical, "c++fly")); + expect(eq(cfg->level, 1000)); // above c++latest's 999 + expect(bool(cfg->experimental)); + expect(!(mcpp::manifest::normalize_cpp_standard("c++latest")->experimental)); + expect(!(mcpp::manifest::normalize_cpp_standard("c++26")->experimental)); auto bad = mcpp::manifest::normalize_cpp_standard("c++flyy"); - ASSERT_FALSE(bad.has_value()); - EXPECT_NE(bad.error().find("c++fly"), std::string::npos); // listed in the allow-list message -} + expect(fatal(!bad.has_value())); + expect(ne(bad.error().find("c++fly"), std::string::npos)); // listed in the allow-list message +}}; -TEST(Manifest, Cpp20Standard) { +UTest cpp20_standard{"Manifest/Cpp20Standard", [] { // C++20 is the floor of the allow-list: named modules are a C++20 feature, // so nothing below it exists for this build model // (.agents/docs/2026-07-31-cpp20-standard-support-design.md). auto cfg = mcpp::manifest::normalize_cpp_standard("c++20"); - ASSERT_TRUE(cfg.has_value()); - EXPECT_EQ(cfg->canonical, "c++20"); - EXPECT_EQ(cfg->flag, "-std=c++20"); - EXPECT_EQ(cfg->level, 20); - EXPECT_FALSE(cfg->gnuDialect); - EXPECT_FALSE(cfg->experimental); + expect(fatal(cfg.has_value())); + expect(eq(cfg->canonical, "c++20")); + expect(eq(cfg->flag, "-std=c++20")); + expect(eq(cfg->level, 20)); + expect(!cfg->gnuDialect); + expect(!cfg->experimental); // Aliases, symmetric with the existing c++2b / c++2c handling. auto alias = mcpp::manifest::normalize_cpp_standard("C++2a"); // also case-folded - ASSERT_TRUE(alias.has_value()); - EXPECT_EQ(alias->canonical, "c++20"); - EXPECT_EQ(alias->level, 20); + expect(fatal(alias.has_value())); + expect(eq(alias->canonical, "c++20")); + expect(eq(alias->level, 20)); auto gnu = mcpp::manifest::normalize_cpp_standard("gnu++20"); - ASSERT_TRUE(gnu.has_value()); - EXPECT_EQ(gnu->canonical, "gnu++20"); - EXPECT_EQ(gnu->flag, "-std=gnu++20"); - EXPECT_EQ(gnu->level, 20); - EXPECT_TRUE(gnu->gnuDialect); - EXPECT_EQ(mcpp::manifest::normalize_cpp_standard("gnu++2a")->canonical, "gnu++20"); + expect(fatal(gnu.has_value())); + expect(eq(gnu->canonical, "gnu++20")); + expect(eq(gnu->flag, "-std=gnu++20")); + expect(eq(gnu->level, 20)); + expect(bool(gnu->gnuDialect)); + expect(eq(mcpp::manifest::normalize_cpp_standard("gnu++2a")->canonical, "gnu++20")); // Below the floor stays rejected, and the message advertises the floor. auto bad = mcpp::manifest::normalize_cpp_standard("c++17"); - ASSERT_FALSE(bad.has_value()); - EXPECT_NE(bad.error().find("c++20"), std::string::npos) << bad.error(); -} + expect(fatal(!bad.has_value())); + if (auto&& failed = expect(ne(bad.error().find("c++20"), std::string::npos)); !failed) failed << bad.error(); +}}; -TEST(Manifest, CppStandardLevelName) { - EXPECT_EQ(mcpp::manifest::cpp_standard_level_name(20), "c++20"); - EXPECT_EQ(mcpp::manifest::cpp_standard_level_name(23), "c++23"); - EXPECT_EQ(mcpp::manifest::cpp_standard_level_name(26), "c++26"); -} +UTest manifest_cpp_standard_level_name{"Manifest/CppStandardLevelName", [] { + expect(eq(mcpp::manifest::cpp_standard_level_name(20), "c++20")); + expect(eq(mcpp::manifest::cpp_standard_level_name(23), "c++23")); + expect(eq(mcpp::manifest::cpp_standard_level_name(26), "c++26")); +}}; -TEST(Manifest, MinimalValid) { +UTest minimal_valid{"Manifest/MinimalValid", [] { constexpr auto src = R"( [package] name = "hello" @@ -120,24 +122,24 @@ kind = "bin" main = "src/main.cpp" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->package.name, "hello"); - EXPECT_EQ(m->package.version, "0.1.0"); - EXPECT_EQ(m->language.standard, "c++23"); - EXPECT_TRUE(m->language.modules); - ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets[0].name, "hello"); - EXPECT_EQ(m->targets[0].kind, mcpp::manifest::Target::Binary); -} - -TEST(Manifest, XlingsSubosPreservesDeclarationPresence) { + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->package.name, "hello")); + expect(eq(m->package.version, "0.1.0")); + expect(eq(m->language.standard, "c++23")); + expect(bool(m->language.modules)); + expect(fatal(eq(m->targets.size(), 1u))); + expect(eq(m->targets[0].name, "hello")); + expect(eq(m->targets[0].kind, mcpp::manifest::Target::Binary)); +}}; + +UTest xlings_subos_preserves_declaration_presence{"Manifest/XlingsSubosPreservesDeclarationPresence", [] { auto absent = mcpp::manifest::parse_string(R"( [package] name = "absent" version = "0.1.0" )"); - ASSERT_TRUE(absent.has_value()) << absent.error().format(); - EXPECT_FALSE(absent->xlings.subosDeclared); + if (auto&& failed = expect(fatal(absent.has_value())); !failed) failed << absent.error().format(); + expect(!(absent->xlings.subosDeclared)); auto explicitDefault = mcpp::manifest::parse_string(R"( [package] @@ -146,9 +148,9 @@ version = "0.1.0" [xlings] subos = "default" )"); - ASSERT_TRUE(explicitDefault.has_value()) << explicitDefault.error().format(); - EXPECT_TRUE(explicitDefault->xlings.subosDeclared); - EXPECT_EQ(explicitDefault->xlings.subos, "default"); + if (auto&& failed = expect(fatal(explicitDefault.has_value())); !failed) failed << explicitDefault.error().format(); + expect(bool(explicitDefault->xlings.subosDeclared)); + expect(eq(explicitDefault->xlings.subos, "default")); auto empty = mcpp::manifest::parse_string(R"( [package] @@ -157,12 +159,12 @@ version = "0.1.0" [xlings] subos = "" )"); - ASSERT_TRUE(empty.has_value()) << empty.error().format(); - EXPECT_TRUE(empty->xlings.subosDeclared); - EXPECT_TRUE(empty->xlings.subos.empty()); -} + if (auto&& failed = expect(fatal(empty.has_value())); !failed) failed << empty.error().format(); + expect(bool(empty->xlings.subosDeclared)); + expect(empty->xlings.subos.empty()); +}}; -TEST(Manifest, SharedTargetSoname) { +UTest shared_target_soname{"Manifest/SharedTargetSoname", [] { constexpr auto src = R"( [package] name = "dep" @@ -174,13 +176,13 @@ kind = "shared" soname = "libdep.so.1" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets[0].kind, mcpp::manifest::Target::SharedLibrary); - EXPECT_EQ(m->targets[0].soname, "libdep.so.1"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->targets.size(), 1u))); + expect(eq(m->targets[0].kind, mcpp::manifest::Target::SharedLibrary)); + expect(eq(m->targets[0].soname, "libdep.so.1")); +}}; -TEST(Manifest, RejectsSonameOnANonLibraryTarget) { +UTest rejects_soname_on_a_non_library_target{"Manifest/RejectsSonameOnANonLibraryTarget", [] { // NARROWED from "non-shared" to "non-library" (#519). A soname is the // name a library is FOUND by, and a package needs to be able to state it // while still being consumed as a static library — otherwise two copies @@ -196,12 +198,11 @@ main = "src/main.cpp" soname = "libapp.so.1" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("soname is only valid for library targets"), - std::string::npos); -} + expect(fatal(!m.has_value())); + expect(ne(m.error().message.find("soname is only valid for library targets"), std::string::npos)); +}}; -TEST(Manifest, PackageStandardCpp26AcceptedAndMirrored) { +UTest package_standard_cpp26_accepted_and_mirrored{"Manifest/PackageStandardCpp26AcceptedAndMirrored", [] { constexpr auto src = R"( [package] name = "hello26" @@ -214,12 +215,12 @@ kind = "bin" main = "src/main.cpp" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->package.standard, "c++26"); - EXPECT_EQ(m->language.standard, "c++26"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->package.standard, "c++26")); + expect(eq(m->language.standard, "c++26")); +}}; -TEST(Manifest, LegacyLanguageCpp2cNormalizesToCpp26) { +UTest legacy_language_cpp2c_normalizes_to_cpp26{"Manifest/LegacyLanguageCpp2cNormalizesToCpp26", [] { constexpr auto src = R"( [package] name = "hello26" @@ -233,12 +234,12 @@ kind = "bin" main = "src/main.cpp" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->package.standard, "c++26"); - EXPECT_EQ(m->language.standard, "c++26"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->package.standard, "c++26")); + expect(eq(m->language.standard, "c++26")); +}}; -TEST(Manifest, RejectsStdFlagInCxxflags) { +UTest rejects_std_flag_in_cxxflags{"Manifest/RejectsStdFlagInCxxflags", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "x" @@ -251,12 +252,11 @@ sources = ["src/**/*.cppm"] kind = "bin" main = "src/main.cpp" )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("[package].standard"), std::string::npos) - << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("[package].standard"), std::string::npos)); !failed) failed << m.error().message; +}}; -TEST(Manifest, RejectMissingVersion) { +UTest reject_missing_version{"Manifest/RejectMissingVersion", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "x" @@ -266,11 +266,11 @@ sources = ["src/**/*.cppm"] kind = "bin" main = "src/main.cpp" )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("package.version"), std::string::npos); -} + expect(fatal(!m.has_value())); + expect(ne(m.error().message.find("package.version"), std::string::npos)); +}}; -TEST(Manifest, AcceptsCpp20WithImportStd) { +UTest accepts_cpp20_with_import_std{"Manifest/AcceptsCpp20WithImportStd", [] { // Was RejectImportStdWithoutCpp23: it asserted this manifest is rejected, // but the only thing rejecting it was the standard allow-list (there has // never been an import_std-specific rule) — so the test's name described @@ -290,14 +290,14 @@ sources = ["src/**/*.cppm"] kind = "bin" main = "src/main.cpp" )"); - ASSERT_TRUE(m.has_value()) << m.error().message; - EXPECT_EQ(m->package.standard, "c++20"); // canonical, mirrored from [language] - EXPECT_EQ(m->language.standard, "c++20"); - EXPECT_EQ(m->cppStandard.level, 20); - EXPECT_EQ(m->cppStandard.flag, "-std=c++20"); -} - -TEST(Manifest, RejectsStandardBelowCpp20) { + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().message; + expect(eq(m->package.standard, "c++20")); // canonical, mirrored from [language] + expect(eq(m->language.standard, "c++20")); + expect(eq(m->cppStandard.level, 20)); + expect(eq(m->cppStandard.flag, "-std=c++20")); +}}; + +UTest rejects_standard_below_cpp20{"Manifest/RejectsStandardBelowCpp20", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "x" @@ -307,12 +307,11 @@ standard = "c++17" kind = "bin" main = "src/main.cpp" )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("c++17"), std::string::npos) - << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("c++17"), std::string::npos)); !failed) failed << m.error().message; +}}; -TEST(Manifest, RejectModulesFalse) { +UTest reject_modules_false{"Manifest/RejectModulesFalse", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "x" @@ -326,10 +325,10 @@ sources = ["src/**/*.cppm"] kind = "bin" main = "src/main.cpp" )"); - ASSERT_FALSE(m.has_value()); -} + expect(fatal(!m.has_value())); +}}; -TEST(Manifest, ParsesDependencies) { +UTest parses_dependencies{"Manifest/ParsesDependencies", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "x" @@ -344,13 +343,13 @@ main = "src/main.cpp" [dev-dependencies] "gtest" = "1.15.2" )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->dependencies.size(), 1u); - EXPECT_EQ(m->dependencies.at("mcpplibs.primitives").version, "0.0.1"); - EXPECT_EQ(m->devDependencies.at("gtest").version, "1.15.2"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->dependencies.size(), 1u)); + expect(eq(m->dependencies.at("mcpplibs.primitives").version, "0.0.1")); + expect(eq(m->devDependencies.at("gtest").version, "1.15.2")); +}}; -TEST(Manifest, ParsesDependencyVisibility) { +UTest parses_dependency_visibility{"Manifest/ParsesDependencyVisibility", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "x" @@ -365,18 +364,18 @@ imgui = { version = "1.92.8", visibility = "private" } glfw = { version = "3.4", visibility = "interface" } opengl = "2026.05.31" )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->dependencies.size(), 3u); - EXPECT_EQ(m->dependencies.at("compat.imgui").visibility, "private"); - EXPECT_EQ(m->dependencies.at("compat.glfw").visibility, "interface"); - EXPECT_EQ(m->dependencies.at("compat.opengl").visibility, "public"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->dependencies.size(), 3u))); + expect(eq(m->dependencies.at("compat.imgui").visibility, "private")); + expect(eq(m->dependencies.at("compat.glfw").visibility, "interface")); + expect(eq(m->dependencies.at("compat.opengl").visibility, "public")); +}}; // #242 — consumer-side `default-features = false`: a consumer opts out of a // dependency's own [features].default set (Cargo parity). The flag parses into // DependencySpec.defaultFeatures; explicitly requested `features = [...]` still // come through. Default (omitted) stays true. -TEST(Manifest, ParsesDependencyDefaultFeaturesOptOut) { +UTest parses_dependency_default_features_opt_out{"Manifest/ParsesDependencyDefaultFeaturesOptOut", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "x" @@ -390,19 +389,19 @@ main = "src/main.cpp" ffmpeg = { version = "6.1", default-features = false, features = ["avcodec"] } zlib = "1.3" )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->dependencies.size(), 2u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->dependencies.size(), 2u))); // Opt-out dependency: flag is false, but the explicit feature still parses. const auto& ff = m->dependencies.at("compat.ffmpeg"); - EXPECT_FALSE(ff.defaultFeatures); - ASSERT_EQ(ff.features.size(), 1u); - EXPECT_EQ(ff.features[0], "avcodec"); - EXPECT_EQ(ff.version, "6.1"); + expect(!ff.defaultFeatures); + expect(fatal(eq(ff.features.size(), 1u))); + expect(eq(ff.features[0], "avcodec")); + expect(eq(ff.version, "6.1")); // Bare-string dependency: defaultFeatures defaults to true. - EXPECT_TRUE(m->dependencies.at("compat.zlib").defaultFeatures); -} + expect(bool(m->dependencies.at("compat.zlib").defaultFeatures)); +}}; -TEST(Manifest, RejectsInvalidDependencyVisibility) { +UTest rejects_invalid_dependency_visibility{"Manifest/RejectsInvalidDependencyVisibility", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "x" @@ -410,18 +409,18 @@ version = "0.1.0" [dependencies.compat] imgui = { version = "1.92.8", visibility = "implementation" } )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("visibility"), std::string::npos); -} + expect(fatal(!m.has_value())); + expect(ne(m.error().message.find("visibility"), std::string::npos)); +}}; -TEST(Manifest, DefaultTemplateRoundTrip) { +UTest default_template_round_trip{"Manifest/DefaultTemplateRoundTrip", [] { auto src = mcpp::manifest::default_template("hello"); auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->package.name, "hello"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->package.name, "hello")); +}}; -TEST(ManifestEditor, UpsertsCanonicalDependencyByExactPackageId) { +UTest upserts_canonical_dependency_by_exact_package_id{"ManifestEditor/UpsertsCanonicalDependencyByExactPackageId", [] { constexpr std::string_view source = R"([package] name = "app" version = "0.1.0" @@ -435,21 +434,19 @@ widget = "1.0.0" .version = "2.0.0", .features = {"gui", "vulkan"}, }); - ASSERT_TRUE(edited.has_value()) << edited.error(); - EXPECT_NE(edited->find("[dependencies.acme]\nwidget = { version = \"2.0.0\", features = [\"gui\", \"vulkan\"] }"), - std::string::npos); + if (auto&& failed = expect(fatal(edited.has_value())); !failed) failed << edited.error(); + expect(ne(edited->find("[dependencies.acme]\nwidget = { version = \"2.0.0\", features = [\"gui\", \"vulkan\"] }"), std::string::npos)); auto parsed = mcpp::manifest::parse_string(*edited); - ASSERT_TRUE(parsed.has_value()) << parsed.error().format(); - ASSERT_EQ(parsed->dependencies.size(), 2u); - EXPECT_EQ(parsed->dependencies.at("acme.widget").namespace_, "acme"); - EXPECT_EQ(parsed->dependencies.at("acme.widget").shortName, "widget"); - EXPECT_EQ(parsed->dependencies.at("acme.widget").version, "2.0.0"); - EXPECT_EQ(parsed->dependencies.at("acme.widget").features, - (std::vector{"gui", "vulkan"})); - EXPECT_EQ(parsed->dependencies.at("compat.widget").version, "1.0.0"); -} - -TEST(ManifestEditor, ReplacesCanonicalEntryAndRejectsInvalidSource) { + if (auto&& failed = expect(fatal(parsed.has_value())); !failed) failed << parsed.error().format(); + expect(fatal(eq(parsed->dependencies.size(), 2u))); + expect(eq(parsed->dependencies.at("acme.widget").namespace_, "acme")); + expect(eq(parsed->dependencies.at("acme.widget").shortName, "widget")); + expect(eq(parsed->dependencies.at("acme.widget").version, "2.0.0")); + expect(eq(parsed->dependencies.at("acme.widget").features, (std::vector{"gui", "vulkan"}))); + expect(eq(parsed->dependencies.at("compat.widget").version, "1.0.0")); +}}; + +UTest replaces_canonical_entry_and_rejects_invalid_source{"ManifestEditor/ReplacesCanonicalEntryAndRejectsInvalidSource", [] { constexpr std::string_view source = R"([package] name = "app" version = "0.1.0" @@ -462,12 +459,11 @@ widget = "1.0.0" .shortName = "widget", .version = "3.0.0", }); - ASSERT_TRUE(edited.has_value()) << edited.error(); + if (auto&& failed = expect(fatal(edited.has_value())); !failed) failed << edited.error(); auto parsed = mcpp::manifest::parse_string(*edited); - ASSERT_TRUE(parsed.has_value()) << parsed.error().format(); - EXPECT_EQ(parsed->dependencies.at("acme.widget").version, "3.0.0"); - EXPECT_EQ(std::ranges::count(*edited, '\n'), - std::ranges::count(source, '\n')); + if (auto&& failed = expect(fatal(parsed.has_value())); !failed) failed << parsed.error().format(); + expect(eq(parsed->dependencies.at("acme.widget").version, "3.0.0")); + expect(eq(std::ranges::count(*edited, '\n'), std::ranges::count(source, '\n'))); auto invalid = mcpp::manifest::upsert_dependency_text( "[package\nname = \"broken\"", { @@ -475,11 +471,11 @@ widget = "1.0.0" .shortName = "widget", .version = "1.0.0", }); - ASSERT_FALSE(invalid.has_value()); - EXPECT_NE(invalid.error().find("invalid manifest"), std::string::npos); -} + expect(fatal(!invalid.has_value())); + expect(ne(invalid.error().find("invalid manifest"), std::string::npos)); +}}; -TEST(ListXpkgVersions, MultipleEntriesAcrossPlatforms) { +UTest multiple_entries_across_platforms{"ListXpkgVersions/MultipleEntriesAcrossPlatforms", [] { constexpr auto src = R"( package = { name = "foo", @@ -496,29 +492,29 @@ package = { } )"; auto linux = mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); - ASSERT_EQ(linux.size(), 3u); - EXPECT_EQ(linux[0], "0.1.0"); - EXPECT_EQ(linux[1], "0.2.0"); - EXPECT_EQ(linux[2], "1.0.0"); + expect(fatal(eq(linux.size(), 3u))); + expect(eq(linux[0], "0.1.0")); + expect(eq(linux[1], "0.2.0")); + expect(eq(linux[2], "1.0.0")); auto mac = mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("macosx")); - ASSERT_EQ(mac.size(), 1u); - EXPECT_EQ(mac[0], "0.1.0"); + expect(fatal(eq(mac.size(), 1u))); + expect(eq(mac[0], "0.1.0")); auto win = mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("windows")); - EXPECT_TRUE(win.empty()); -} + expect(win.empty()); +}}; -TEST(ListXpkgVersions, MissingXpmReturnsEmpty) { +UTest missing_xpm_returns_empty{"ListXpkgVersions/MissingXpmReturnsEmpty", [] { constexpr auto src = R"(package = { name = "foo" })"; - EXPECT_TRUE(mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")).empty()); -} + expect(mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")).empty()); +}}; // #363: `["25.0.4"] = { ref = "25.0.4.7.1" }` is a POINTER at another entry, // not a release. Shape copied from the real jdk-corretto / jdk-temurin // descriptors. Treating it as a version gave range resolution a candidate that // ties with (or truncates to something other than) its own target. -TEST(ListXpkgVersions, AliasEntriesAreFlagged) { +UTest alias_entries_are_flagged{"ListXpkgVersions/AliasEntriesAreFlagged", [] { constexpr auto src = R"( package = { name = "jdk", @@ -533,26 +529,26 @@ package = { )"; auto e = mcpp::manifest::list_xpkg_version_entries( src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); - ASSERT_EQ(e.size(), 3u); - EXPECT_EQ(e[0].version, "latest"); EXPECT_TRUE (e[0].alias); - EXPECT_EQ(e[1].version, "25.0.4"); EXPECT_TRUE (e[1].alias); - EXPECT_EQ(e[2].version, "25.0.4.7.1"); EXPECT_FALSE(e[2].alias); - EXPECT_TRUE(e[0].sha256.empty()); - EXPECT_TRUE(e[1].sha256.empty()); - EXPECT_EQ(e[2].sha256, "z"); + expect(fatal(eq(e.size(), 3u))); + expect(eq(e[0].version, "latest")); expect(bool(e[0].alias)); + expect(eq(e[1].version, "25.0.4")); expect(bool(e[1].alias)); + expect(eq(e[2].version, "25.0.4.7.1")); expect(!(e[2].alias)); + expect(e[0].sha256.empty()); + expect(e[1].sha256.empty()); + expect(eq(e[2].sha256, "z")); // The keys-only view is unchanged for every existing caller. auto keys = mcpp::manifest::list_xpkg_versions( src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); - ASSERT_EQ(keys.size(), 3u); - EXPECT_EQ(keys[2], "25.0.4.7.1"); -} + expect(fatal(eq(keys.size(), 3u))); + expect(eq(keys[2], "25.0.4.7.1")); +}}; // openxlings/xlings#620, mcpp#524 A: a version entry's `revision` is read the // way the reference implementation reads it -- a non-negative integer, and 0 // for anything else or for its absence -- including the per-arch shape, whose // arch tables must not be mistaken for the revision. -TEST(ListXpkgVersions, RevisionIsReadPerEntry) { +UTest revision_is_read_per_entry{"ListXpkgVersions/RevisionIsReadPerEntry", [] { constexpr auto src = R"( package = { name = "rev", @@ -570,19 +566,19 @@ package = { )"; auto e = mcpp::manifest::list_xpkg_version_entries( src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); - ASSERT_EQ(e.size(), 6u); - EXPECT_EQ(e[0].revision, 0); // an alias carries none - EXPECT_EQ(e[1].revision, 0); - EXPECT_EQ(e[2].revision, 2); - EXPECT_EQ(e[3].revision, 1); - EXPECT_EQ(e[4].revision, 0); // a string is not a revision - EXPECT_EQ(e[5].revision, 0); // nor is a negative number -} + expect(fatal(eq(e.size(), 6u))); + expect(eq(e[0].revision, 0)); // an alias carries none + expect(eq(e[1].revision, 0)); + expect(eq(e[2].revision, 2)); + expect(eq(e[3].revision, 1)); + expect(eq(e[4].revision, 0)); // a string is not a revision + expect(eq(e[5].revision, 0)); // nor is a negative number +}}; // The scanner used to walk the platform table character by character, so a // bracket key nested inside a version's own body (mirror tables write // `["GLOBAL"] = "https://..."`) counted as a published version. -TEST(ListXpkgVersions, NestedBracketKeysAreNotVersions) { +UTest nested_bracket_keys_are_not_versions{"ListXpkgVersions/NestedBracketKeysAreNotVersions", [] { constexpr auto src = R"( package = { name = "foo", @@ -599,12 +595,12 @@ package = { )"; auto keys = mcpp::manifest::list_xpkg_versions( src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); - ASSERT_EQ(keys.size(), 2u); - EXPECT_EQ(keys[0], "1.0.0"); - EXPECT_EQ(keys[1], "1.1.0"); -} + expect(fatal(eq(keys.size(), 2u))); + expect(eq(keys[0], "1.0.0")); + expect(eq(keys[1], "1.1.0")); +}}; -TEST(Manifest, BuildCflagsCxxflagsAndCStandard) { +UTest build_cflags_cxxflags_and_c_standard{"Manifest/BuildCflagsCxxflagsAndCStandard", [] { constexpr auto src = R"( [package] name = "x" @@ -619,23 +615,23 @@ c_standard = "c11" kind = "lib" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->buildConfig.cflags.size(), 2u); - EXPECT_EQ(m->buildConfig.cflags[0], "-Wall"); - EXPECT_EQ(m->buildConfig.cflags[1], "-DFOO=1"); - ASSERT_EQ(m->buildConfig.cxxflags.size(), 1u); - EXPECT_EQ(m->buildConfig.cxxflags[0], "-Wextra"); - ASSERT_EQ(m->buildConfig.ldflags.size(), 2u); - EXPECT_EQ(m->buildConfig.ldflags[0], "-lfoo"); - EXPECT_EQ(m->buildConfig.ldflags[1], "-Wl,--as-needed"); - EXPECT_EQ(m->buildConfig.cStandard, "c11"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->buildConfig.cflags.size(), 2u))); + expect(eq(m->buildConfig.cflags[0], "-Wall")); + expect(eq(m->buildConfig.cflags[1], "-DFOO=1")); + expect(fatal(eq(m->buildConfig.cxxflags.size(), 1u))); + expect(eq(m->buildConfig.cxxflags[0], "-Wextra")); + expect(fatal(eq(m->buildConfig.ldflags.size(), 2u))); + expect(eq(m->buildConfig.ldflags[0], "-lfoo")); + expect(eq(m->buildConfig.ldflags[1], "-Wl,--as-needed")); + expect(eq(m->buildConfig.cStandard, "c11")); +}}; // #296: [build].defines is sugar for `-D` on both C and C++ channels; it // must parse into buildConfig.defines so prepare_build can fold it into flags // before the P1689 scan and fingerprint. Order is preserved because the fold // appends in declaration order and "last flag wins" is the override semantics. -TEST(Manifest, BuildDefinesParsesIntoSeparateVector) { +UTest build_defines_parses_into_separate_vector{"Manifest/BuildDefinesParsesIntoSeparateVector", [] { constexpr auto src = R"( [package] name = "x" @@ -644,15 +640,15 @@ version = "0.1.0" defines = ["TEST_USE_MODULES", "VALUE=42"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->buildConfig.defines.size(), 2u); - EXPECT_EQ(m->buildConfig.defines[0], "TEST_USE_MODULES"); - EXPECT_EQ(m->buildConfig.defines[1], "VALUE=42"); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->buildConfig.defines.size(), 2u))); + expect(eq(m->buildConfig.defines[0], "TEST_USE_MODULES")); + expect(eq(m->buildConfig.defines[1], "VALUE=42")); // A well-formed [build] must not warn — the unknown-key guard below is // only allowed to fire on keys the parser genuinely does not read. - EXPECT_TRUE(m->schemaWarnings.empty()) + expect(m->schemaWarnings.empty()) << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); -} +}}; // ── #540: the three vocabularies, and the drift that keeps happening ──────── // @@ -675,7 +671,7 @@ defines = ["TEST_USE_MODULES", "VALUE=42"] // let this happen twice. Add a key to the parser, add it here; if you forget, // the negative control at the bottom of each test still passes and this one // goes red. -TEST(Manifest, EveryBuildKeyTheParserReadsIsAccepted) { +UTest every_build_key_the_parser_reads_is_accepted{"Manifest/EveryBuildKeyTheParserReadsIsAccepted", [] { constexpr auto src = R"( [package] name = "x" @@ -710,23 +706,23 @@ std-compat-module = "gen/std.compat.cppm" std-module-flags = ["-D_GNU_SOURCE"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); std::string all; for (auto const& w : m->schemaWarnings) { all += w; all += '\n'; } - EXPECT_TRUE(m->schemaWarnings.empty()) + expect(m->schemaWarnings.empty()) << "a key the parser reads was reported as unsupported:\n" << all; // The three that were absent, asserted individually so a regression names // itself rather than arriving as "some warning appeared". - EXPECT_EQ(m->stdModule, "gen/std.cppm"); - EXPECT_EQ(m->stdCompatModule, "gen/std.compat.cppm"); - ASSERT_EQ(m->buildConfig.stdModuleFlags.size(), 1u); - EXPECT_EQ(m->buildConfig.stdModuleFlags[0], "-D_GNU_SOURCE"); -} + expect(eq(m->stdModule, "gen/std.cppm")); + expect(eq(m->stdCompatModule, "gen/std.compat.cppm")); + expect(fatal(eq(m->buildConfig.stdModuleFlags.size(), 1u))); + expect(eq(m->buildConfig.stdModuleFlags[0], "-D_GNU_SOURCE")); +}}; // The negative control. Without it the test above passes against a parser whose // unknown-key check has been deleted outright, which is the failure mode a // "no warnings" assertion invites. -TEST(Manifest, AnUnknownBuildKeyIsStillReported) { +UTest an_unknown_build_key_is_still_reported{"Manifest/AnUnknownBuildKeyIsStillReported", [] { constexpr auto src = R"( [package] name = "x" @@ -735,18 +731,18 @@ version = "0.1.0" std_module = "gen/std.cppm" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->schemaWarnings.size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->schemaWarnings.size(), 1u))); // The underscore spelling is the plausible typo for the hyphenated key, so // it is the one worth holding: accepting it silently is how `schedule` got // in while `bmi_schedule` was being reported as unsupported. - EXPECT_NE(m->schemaWarnings[0].find("std_module"), std::string::npos) + expect(ne(m->schemaWarnings[0].find("std_module"), std::string::npos)) << m->schemaWarnings[0]; // The message must carry the SAME list the check uses — it used to be a // third hand-written copy and had drifted from both others. - EXPECT_NE(m->schemaWarnings[0].find("std-module"), std::string::npos) + expect(ne(m->schemaWarnings[0].find("std-module"), std::string::npos)) << "the message must name the supported keys it actually checks against"; -} +}}; // `kKnownConditionalBuildKeys`' own comment says its vocabulary "is exactly // that struct's members" and "MUST stay in sync with the reads above and with @@ -755,7 +751,7 @@ std_module = "gen/std.cppm" // `target_cfg` block, the other grammar for this same axis. So one spelling of // a conditional private include directory worked and the other reported the // key as unsupported. -TEST(Manifest, EveryConditionalBuildKeyTheAxisCarriesIsAccepted) { +UTest every_conditional_build_key_the_axis_carries_is_accepted{"Manifest/EveryConditionalBuildKeyTheAxisCarriesIsAccepted", [] { constexpr auto src = R"( [package] name = "x" @@ -772,24 +768,24 @@ sources = ["src/extra.cpp"] std-module-flags = ["-D_GNU_SOURCE"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); std::string all; for (auto const& w : m->schemaWarnings) { all += w; all += '\n'; } - EXPECT_TRUE(m->schemaWarnings.empty()) + expect(m->schemaWarnings.empty()) << "a BuildInputs member the axis carries was reported unsupported:\n" << all; - ASSERT_EQ(m->conditionalConfigs.size(), 1u); + expect(fatal(eq(m->conditionalConfigs.size(), 1u))); auto const& in = m->conditionalConfigs[0].inputs; // #494 moved `std-module-flags` onto BuildInputs specifically so this axis // could carry it — "membership here is what makes the cfg axis carry it", // with -D_GNU_SOURCE right for musl and glibc and wrong for picolibc as the // stated case. The member and the merge landed; the READ did not. - ASSERT_EQ(in.stdModuleFlags.size(), 1u); - EXPECT_EQ(in.stdModuleFlags[0], "-D_GNU_SOURCE"); - ASSERT_EQ(in.privateIncludeDirs.size(), 1u); - EXPECT_EQ(in.privateIncludeDirs[0].generic_string(), "internal"); -} + expect(fatal(eq(in.stdModuleFlags.size(), 1u))); + expect(eq(in.stdModuleFlags[0], "-D_GNU_SOURCE")); + expect(fatal(eq(in.privateIncludeDirs.size(), 1u))); + expect(eq(in.privateIncludeDirs[0].generic_string(), "internal")); +}}; -TEST(Manifest, AnUnknownConditionalBuildKeyIsStillReported) { +UTest an_unknown_conditional_build_key_is_still_reported{"Manifest/AnUnknownConditionalBuildKeyIsStillReported", [] { constexpr auto src = R"( [package] name = "x" @@ -798,15 +794,14 @@ version = "0.1.0" static_stdlib = true )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->schemaWarnings.size(), 1u); - EXPECT_NE(m->schemaWarnings[0].find("static_stdlib"), std::string::npos) - << m->schemaWarnings[0]; + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->schemaWarnings.size(), 1u))); + if (auto&& failed = expect(ne(m->schemaWarnings[0].find("static_stdlib"), std::string::npos)); !failed) failed << m->schemaWarnings[0]; // Built from the list, not written beside it: the prose form named eight // keys and was two behind the check it was describing. - EXPECT_NE(m->schemaWarnings[0].find("std-module-flags"), std::string::npos) + expect(ne(m->schemaWarnings[0].find("std-module-flags"), std::string::npos)) << "the message must be generated from the same list the check uses"; -} +}}; // #717: `dialect_cxxflags` under `[target..build]` used to be // reported as "unsupported key 'dialect_cxxflags' (ignored)" and reached no @@ -814,7 +809,7 @@ static_stdlib = true // and parsed into `ConditionalConfig::dialectCxxflags` -- a member of its own, // NOT of `BuildInputs` (`.inputs`), because the key is graph-wide rather than // a per-package additive input (design 2026-09-27 §6.2). -TEST(Manifest, ConditionalDialectCxxflagsAcceptedAndKeptOffBuildInputs) { +UTest conditional_dialect_cxxflags_accepted_and_kept_off_build_inputs{"Manifest/ConditionalDialectCxxflagsAcceptedAndKeptOffBuildInputs", [] { constexpr auto src = R"( [package] name = "x" @@ -824,24 +819,23 @@ cxxflags = ["-DORDINARY"] dialect_cxxflags = ["-DDIALECT_A", "-DDIALECT_B"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_TRUE(m->schemaWarnings.empty()) - << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); - ASSERT_EQ(m->conditionalConfigs.size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + if (auto&& failed = expect(m->schemaWarnings.empty()); !failed) failed << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); + expect(fatal(eq(m->conditionalConfigs.size(), 1u))); auto const& cc = m->conditionalConfigs[0]; - ASSERT_EQ(cc.dialectCxxflags.size(), 2u); - EXPECT_EQ(cc.dialectCxxflags[0], "-DDIALECT_A"); - EXPECT_EQ(cc.dialectCxxflags[1], "-DDIALECT_B"); + expect(fatal(eq(cc.dialectCxxflags.size(), 2u))); + expect(eq(cc.dialectCxxflags[0], "-DDIALECT_A")); + expect(eq(cc.dialectCxxflags[1], "-DDIALECT_B")); // An ordinary per-package build input written in the SAME section still // lands on BuildInputs, as always -- only `dialect_cxxflags` is diverted. - ASSERT_EQ(cc.inputs.cxxflags.size(), 1u); - EXPECT_EQ(cc.inputs.cxxflags[0], "-DORDINARY"); -} + expect(fatal(eq(cc.inputs.cxxflags.size(), 1u))); + expect(eq(cc.inputs.cxxflags[0], "-DORDINARY")); +}}; // The negative control for the positive test above: a section carrying ONLY // `dialect_cxxflags` must still be recorded, the same emptiness-gate rule // `ConditionalSectionWithOnlyDefinesIsRecorded` states for `defines`. -TEST(Manifest, ConditionalSectionWithOnlyDialectCxxflagsIsRecorded) { +UTest conditional_section_with_only_dialect_cxxflags_is_recorded{"Manifest/ConditionalSectionWithOnlyDialectCxxflagsIsRecorded", [] { constexpr auto src = R"( [package] name = "x" @@ -850,12 +844,12 @@ version = "0.1.0" dialect_cxxflags = ["-DONLY_DIALECT"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_TRUE(m->schemaWarnings.empty()); - ASSERT_EQ(m->conditionalConfigs.size(), 1u); - ASSERT_EQ(m->conditionalConfigs[0].dialectCxxflags.size(), 1u); - EXPECT_EQ(m->conditionalConfigs[0].dialectCxxflags[0], "-DONLY_DIALECT"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(m->schemaWarnings.empty()); + expect(fatal(eq(m->conditionalConfigs.size(), 1u))); + expect(fatal(eq(m->conditionalConfigs[0].dialectCxxflags.size(), 1u))); + expect(eq(m->conditionalConfigs[0].dialectCxxflags[0], "-DONLY_DIALECT")); +}}; // The resolution rules: only a matching row contributes, a non-matching one // contributes nothing, and the order is the root's own `[build]` first, then @@ -866,7 +860,7 @@ namespace manifest_dialect { mcpp::manifest::Manifest merged_for(std::string_view src, std::string_view triple) { auto m = mcpp::manifest::parse_string(src); - EXPECT_TRUE(m.has_value()) << (m.has_value() ? "" : m.error().format()); + if (auto&& failed = expect(m.has_value()); !failed) failed << (m.has_value() ? "" : m.error().format()); if (!m) return {}; mcpp::build::merge_conditional_config(*m, mcpp::build::cfgpred::context_for(triple)); return *m; @@ -874,7 +868,7 @@ mcpp::manifest::Manifest merged_for(std::string_view src, std::string_view tripl } // namespace manifest_dialect -TEST(ConditionalDialectCxxflags, RootBuildPrecedesTheMatchingRow) { +UTest root_build_precedes_the_matching_row{"ConditionalDialectCxxflags/RootBuildPrecedesTheMatchingRow", [] { constexpr auto src = R"( [package] name = "x" @@ -887,10 +881,10 @@ dialect_cxxflags = ["-DLINUX_ONE"] dialect_cxxflags = ["-DWINDOWS_ONLY"] )"; auto lin = manifest_dialect::merged_for(src, "x86_64-unknown-linux-gnu"); - ASSERT_EQ(lin.buildConfig.dialectCxxflags.size(), 2u); - EXPECT_EQ(lin.buildConfig.dialectCxxflags[0], "-DBASE"); - EXPECT_EQ(lin.buildConfig.dialectCxxflags[1], "-DLINUX_ONE"); -} + expect(fatal(eq(lin.buildConfig.dialectCxxflags.size(), 2u))); + expect(eq(lin.buildConfig.dialectCxxflags[0], "-DBASE")); + expect(eq(lin.buildConfig.dialectCxxflags[1], "-DLINUX_ONE")); +}}; // Two DIFFERENT selectors that both match the same resolved triple both // contribute -- this is not "last matching wins" (that rule belongs to a @@ -900,7 +894,7 @@ dialect_cxxflags = ["-DWINDOWS_ONLY"] // is populated from the underlying `Table` (`std::map`, ordered by selector // text), not by source position, which is a property of the parser this // feature inherits rather than one it introduces or could change. -TEST(ConditionalDialectCxxflags, TwoMatchingSelectorsBothContribute) { +UTest two_matching_selectors_both_contribute{"ConditionalDialectCxxflags/TwoMatchingSelectorsBothContribute", [] { constexpr auto src = R"( [package] name = "x" @@ -913,17 +907,16 @@ dialect_cxxflags = ["-DLINUX_ONE"] dialect_cxxflags = ["-DX86"] )"; auto lin = manifest_dialect::merged_for(src, "x86_64-unknown-linux-gnu"); - ASSERT_EQ(lin.buildConfig.dialectCxxflags.size(), 3u); - EXPECT_EQ(lin.buildConfig.dialectCxxflags[0], "-DBASE") - << "the root's own unconditional [build] value always leads: it is " + expect(fatal(eq(lin.buildConfig.dialectCxxflags.size(), 3u))); + if (auto&& failed = expect(eq(lin.buildConfig.dialectCxxflags[0], "-DBASE")); !failed) failed << "the root's own unconditional [build] value always leads: it is " "set before the conditional-merge loop runs, regardless of which " "matching row the loop visits first"; auto const& flags = lin.buildConfig.dialectCxxflags; - EXPECT_NE(std::find(flags.begin(), flags.end(), "-DLINUX_ONE"), flags.end()); - EXPECT_NE(std::find(flags.begin(), flags.end(), "-DX86"), flags.end()); -} + expect(ne(std::find(flags.begin(), flags.end(), "-DLINUX_ONE"), flags.end())); + expect(ne(std::find(flags.begin(), flags.end(), "-DX86"), flags.end())); +}}; -TEST(ConditionalDialectCxxflags, ASelectorThatDoesNotMatchContributesNothing) { +UTest a_selector_that_does_not_match_contributes_nothing{"ConditionalDialectCxxflags/ASelectorThatDoesNotMatchContributesNothing", [] { constexpr auto src = R"( [package] name = "x" @@ -934,15 +927,15 @@ dialect_cxxflags = ["-DBASE"] dialect_cxxflags = ["-DWINDOWS_ONLY"] )"; auto lin = manifest_dialect::merged_for(src, "x86_64-unknown-linux-gnu"); - ASSERT_EQ(lin.buildConfig.dialectCxxflags.size(), 1u); - EXPECT_EQ(lin.buildConfig.dialectCxxflags[0], "-DBASE"); -} + expect(fatal(eq(lin.buildConfig.dialectCxxflags.size(), 1u))); + expect(eq(lin.buildConfig.dialectCxxflags[0], "-DBASE")); +}}; // #540: `[features]` was the one structured section with no schema check at // all, so a misplaced `include_dirs` inside a feature built successfully with // zero diagnostics — while the identical misplacement in `[build]` or // `[target..build]` is reported. -TEST(Manifest, EveryFeatureKeyTheParserReadsIsAccepted) { +UTest every_feature_key_the_parser_reads_is_accepted{"Manifest/EveryFeatureKeyTheParserReadsIsAccepted", [] { constexpr auto src = R"( [package] name = "x" @@ -952,14 +945,14 @@ full = { implies = ["fast"], forward = ["dep/feat"], defines = ["WITH_FULL"], so fast = [] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); std::string all; for (auto const& w : m->schemaWarnings) { all += w; all += '\n'; } - EXPECT_TRUE(m->schemaWarnings.empty()) + expect(m->schemaWarnings.empty()) << "a feature key the parser reads was reported as unsupported:\n" << all; -} +}}; -TEST(Manifest, AnUnknownFeatureKeyIsReported) { +UTest an_unknown_feature_key_is_reported{"Manifest/AnUnknownFeatureKeyIsReported", [] { constexpr auto src = R"( [package] name = "x" @@ -968,19 +961,18 @@ version = "0.1.0" fast = { implies = [], include_dirs = ["nope"] } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->schemaWarnings.size(), 1u); - EXPECT_NE(m->schemaWarnings[0].find("include_dirs"), std::string::npos) - << m->schemaWarnings[0]; - EXPECT_NE(m->schemaWarnings[0].find("fast"), std::string::npos) + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->schemaWarnings.size(), 1u))); + if (auto&& failed = expect(ne(m->schemaWarnings[0].find("include_dirs"), std::string::npos)); !failed) failed << m->schemaWarnings[0]; + expect(ne(m->schemaWarnings[0].find("fast"), std::string::npos)) << "the message must name WHICH feature carries the key"; -} +}}; // `deps` is reserved rather than wrong — the parser's own comment has promised // it since Feature System v2 — so it is reported as reserved. Saying // "unsupported" would deny a documented plan; saying nothing is what let it // look implemented. -TEST(Manifest, AReservedFeatureKeySaysItIsReserved) { +UTest a_reserved_feature_key_says_it_is_reserved{"Manifest/AReservedFeatureKeySaysItIsReserved", [] { constexpr auto src = R"( [package] name = "x" @@ -989,18 +981,17 @@ version = "0.1.0" fast = { implies = [], deps = ["zlib"] } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->schemaWarnings.size(), 1u); - EXPECT_NE(m->schemaWarnings[0].find("reserved"), std::string::npos) - << m->schemaWarnings[0]; -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->schemaWarnings.size(), 1u))); + if (auto&& failed = expect(ne(m->schemaWarnings[0].find("reserved"), std::string::npos)); !failed) failed << m->schemaWarnings[0]; +}}; // #296: `defines` is a BuildInputs member, NOT a BuildConfig-only field — so // the cfg axis carries it like any other build input. This is the regression // that matters: a platform-only macro must be expressible, because a key that // parses unconditionally but vanishes under `[target.'cfg(...)'.build]` is the // exact silent-drop failure #296 was filed for, just one section over. -TEST(Manifest, ConditionalBuildDefinesAreCarriedByTheCfgAxis) { +UTest conditional_build_defines_are_carried_by_the_cfg_axis{"Manifest/ConditionalBuildDefinesAreCarriedByTheCfgAxis", [] { constexpr auto src = R"( [package] name = "x" @@ -1011,21 +1002,20 @@ defines = ["BASE"] defines = ["USE_WIN32", "WINVER=0x0A00"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->conditionalConfigs.size(), 1u); - EXPECT_EQ(m->conditionalConfigs[0].predicate, "cfg(windows)"); - ASSERT_EQ(m->conditionalConfigs[0].inputs.defines.size(), 2u); - EXPECT_EQ(m->conditionalConfigs[0].inputs.defines[0], "USE_WIN32"); - EXPECT_EQ(m->conditionalConfigs[0].inputs.defines[1], "WINVER=0x0A00"); - EXPECT_TRUE(m->schemaWarnings.empty()) - << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->conditionalConfigs.size(), 1u))); + expect(eq(m->conditionalConfigs[0].predicate, "cfg(windows)")); + expect(fatal(eq(m->conditionalConfigs[0].inputs.defines.size(), 2u))); + expect(eq(m->conditionalConfigs[0].inputs.defines[0], "USE_WIN32")); + expect(eq(m->conditionalConfigs[0].inputs.defines[1], "WINVER=0x0A00")); + if (auto&& failed = expect(m->schemaWarnings.empty()); !failed) failed << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); +}}; // A conditional section carrying ONLY `defines` must still be recorded — the // emptiness gate that decides whether to push a ConditionalConfig has to know // about every BuildInputs member, or the section is dropped before it is ever // evaluated. -TEST(Manifest, ConditionalSectionWithOnlyDefinesIsRecorded) { +UTest conditional_section_with_only_defines_is_recorded{"Manifest/ConditionalSectionWithOnlyDefinesIsRecorded", [] { constexpr auto src = R"( [package] name = "x" @@ -1034,32 +1024,32 @@ version = "0.1.0" defines = ["ONLY_DEFINE"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->conditionalConfigs.size(), 1u); - ASSERT_EQ(m->conditionalConfigs[0].inputs.defines.size(), 1u); - EXPECT_EQ(m->conditionalConfigs[0].inputs.defines[0], "ONLY_DEFINE"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->conditionalConfigs.size(), 1u))); + expect(fatal(eq(m->conditionalConfigs[0].inputs.defines.size(), 1u))); + expect(eq(m->conditionalConfigs[0].inputs.defines[0], "ONLY_DEFINE")); +}}; // The single additive merge must carry `defines` too — merge_conditional_ // build_inputs routes the cfg axis through append(), so a member missing here // is a member that silently never reaches the build. -TEST(Manifest, AppendBuildInputsMergesDefines) { +UTest append_build_inputs_merges_defines{"Manifest/AppendBuildInputsMergesDefines", [] { mcpp::manifest::BuildInputs dst, src; dst.defines = {"BASE"}; src.defines = {"COND"}; mcpp::manifest::append(dst, src); - ASSERT_EQ(dst.defines.size(), 2u); - EXPECT_EQ(dst.defines[0], "BASE"); + expect(fatal(eq(dst.defines.size(), 2u))); + expect(eq(dst.defines[0], "BASE")); // Conditional entries land AFTER the base ones so GNU last-wins gives the // conditional rule precedence (an off-OS `-U` after the base `-D`). - EXPECT_EQ(dst.defines[1], "COND"); -} + expect(eq(dst.defines[1], "COND")); +}}; // #296 root cause: an unknown [build] key used to vanish without a word, and // the build then failed much later with a module-graph divergence naming // neither the key nor the manifest. Same policy as [targets.]: a schema // warning (an error under --strict). -TEST(Manifest, UnknownBuildKeyIsReportedNotSilentlyDropped) { +UTest unknown_build_key_is_reported_not_silently_dropped{"Manifest/UnknownBuildKeyIsReportedNotSilentlyDropped", [] { constexpr auto src = R"( [package] name = "x" @@ -1069,19 +1059,17 @@ cxxflags = ["-Wall"] defnes = ["TYPO"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->schemaWarnings.size(), 1u); - EXPECT_NE(m->schemaWarnings[0].find("defnes"), std::string::npos) - << m->schemaWarnings[0]; - EXPECT_NE(m->schemaWarnings[0].find("[build]"), std::string::npos) - << m->schemaWarnings[0]; + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->schemaWarnings.size(), 1u))); + if (auto&& failed = expect(ne(m->schemaWarnings[0].find("defnes"), std::string::npos)); !failed) failed << m->schemaWarnings[0]; + if (auto&& failed = expect(ne(m->schemaWarnings[0].find("[build]"), std::string::npos)); !failed) failed << m->schemaWarnings[0]; // The key is still ignored — this is a warning, not a parse failure. - EXPECT_EQ(m->buildConfig.cxxflags.size(), 1u); -} + expect(eq(m->buildConfig.cxxflags.size(), 1u)); +}}; // The conditional axis may only contribute build INPUTS, so a selection knob // under it is inexpressible by construction. Say so rather than dropping it. -TEST(Manifest, UnknownConditionalBuildKeyIsReported) { +UTest unknown_conditional_build_key_is_reported{"Manifest/UnknownConditionalBuildKeyIsReported", [] { constexpr auto src = R"( [package] name = "x" @@ -1091,18 +1079,16 @@ cxxflags = ["-DOK"] static_stdlib = false )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->schemaWarnings.size(), 1u); - EXPECT_NE(m->schemaWarnings[0].find("static_stdlib"), std::string::npos) - << m->schemaWarnings[0]; - EXPECT_NE(m->schemaWarnings[0].find("cfg(windows)"), std::string::npos) - << m->schemaWarnings[0]; -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->schemaWarnings.size(), 1u))); + if (auto&& failed = expect(ne(m->schemaWarnings[0].find("static_stdlib"), std::string::npos)); !failed) failed << m->schemaWarnings[0]; + if (auto&& failed = expect(ne(m->schemaWarnings[0].find("cfg(windows)"), std::string::npos)); !failed) failed << m->schemaWarnings[0]; +}}; // Every key the [build] parser actually reads must be absent from the // unknown-key warning — a guard that fires on a supported key is worse than // no guard, because --strict turns it into a build failure. -TEST(Manifest, EverySupportedBuildKeyPassesTheUnknownKeyGuard) { +UTest every_supported_build_key_passes_the_unknown_key_guard{"Manifest/EverySupportedBuildKeyPassesTheUnknownKeyGuard", [] { constexpr auto src = R"( [package] name = "x" @@ -1125,14 +1111,13 @@ profile = "dev" macos_deployment_target = "14.0" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_TRUE(m->schemaWarnings.empty()) - << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + if (auto&& failed = expect(m->schemaWarnings.empty()); !failed) failed << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); +}}; // `default-profile` is the canonical spelling of the `profile` alias; both // must clear the guard. -TEST(Manifest, BuildDefaultProfileSpellingPassesTheUnknownKeyGuard) { +UTest build_default_profile_spelling_passes_the_unknown_key_guard{"Manifest/BuildDefaultProfileSpellingPassesTheUnknownKeyGuard", [] { constexpr auto src = R"( [package] name = "x" @@ -1141,18 +1126,17 @@ version = "0.1.0" default-profile = "dev" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->buildConfig.defaultProfile, "dev"); - EXPECT_TRUE(m->schemaWarnings.empty()) - << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->buildConfig.defaultProfile, "dev")); + if (auto&& failed = expect(m->schemaWarnings.empty()); !failed) failed << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); +}}; // #249: `[build] include_dirs_after` parses into buildConfig.includeDirsAfter // — the -idirafter channel (searched AFTER the toolchain's system dirs), so // an extracted-tarball root containing a file named like a standard header // (ffmpeg's VERSION vs libc++'s on case-insensitive macOS) can't // shadow it while its real headers stay findable. -TEST(Manifest, BuildIncludeDirsAfterToml) { +UTest build_include_dirs_after_toml{"Manifest/BuildIncludeDirsAfterToml", [] { constexpr auto src = R"( [package] name = "x" @@ -1165,19 +1149,19 @@ include_dirs_after = ["*", "vendor/include"] kind = "lib" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->buildConfig.includeDirs.size(), 1u); - EXPECT_EQ(m->buildConfig.includeDirs[0], "include"); - ASSERT_EQ(m->buildConfig.includeDirsAfter.size(), 2u); - EXPECT_EQ(m->buildConfig.includeDirsAfter[0], "*"); - EXPECT_EQ(m->buildConfig.includeDirsAfter[1], "vendor/include"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->buildConfig.includeDirs.size(), 1u))); + expect(eq(m->buildConfig.includeDirs[0], "include")); + expect(fatal(eq(m->buildConfig.includeDirsAfter.size(), 2u))); + expect(eq(m->buildConfig.includeDirsAfter[0], "*")); + expect(eq(m->buildConfig.includeDirsAfter[1], "vendor/include")); +}}; // Feature System v2 Stage 1: a [features] entry may be a TABLE carrying // package-owned `defines` (and `implies`), while the array shorthand keeps // meaning "implied features". See // .agents/docs/2026-06-29-feature-capability-model-design.md. -TEST(Manifest, FeatureTableFormDefinesAndImplies) { +UTest feature_table_form_defines_and_implies{"Manifest/FeatureTableFormDefinesAndImplies", [] { constexpr auto src = R"( [package] name = "x" @@ -1190,31 +1174,31 @@ base = [] accel = { defines = ["APP_ACCEL=1", "APP_FAST"], implies = ["base"] } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); // Array shorthand still registers an implied-feature list. - ASSERT_TRUE(m->featuresMap.contains("default")); - ASSERT_EQ(m->featuresMap["default"].size(), 1u); - EXPECT_EQ(m->featuresMap["default"][0], "base"); + expect(fatal(m->featuresMap.contains("default"))); + expect(fatal(eq(m->featuresMap["default"].size(), 1u))); + expect(eq(m->featuresMap["default"][0], "base")); // Table form: `implies` flows into featuresMap, `defines` into featureDefines. - ASSERT_TRUE(m->featuresMap.contains("accel")); - ASSERT_EQ(m->featuresMap["accel"].size(), 1u); - EXPECT_EQ(m->featuresMap["accel"][0], "base"); + expect(fatal(m->featuresMap.contains("accel"))); + expect(fatal(eq(m->featuresMap["accel"].size(), 1u))); + expect(eq(m->featuresMap["accel"][0], "base")); - ASSERT_TRUE(m->buildConfig.featureDefines.contains("accel")); - ASSERT_EQ(m->buildConfig.featureDefines["accel"].size(), 2u); - EXPECT_EQ(m->buildConfig.featureDefines["accel"][0], "APP_ACCEL=1"); - EXPECT_EQ(m->buildConfig.featureDefines["accel"][1], "APP_FAST"); + expect(fatal(m->buildConfig.featureDefines.contains("accel"))); + expect(fatal(eq(m->buildConfig.featureDefines["accel"].size(), 2u))); + expect(eq(m->buildConfig.featureDefines["accel"][0], "APP_ACCEL=1")); + expect(eq(m->buildConfig.featureDefines["accel"][1], "APP_FAST")); // A feature with no defines contributes no featureDefines entry. - EXPECT_FALSE(m->buildConfig.featureDefines.contains("base")); -} + expect(!(m->buildConfig.featureDefines.contains("base"))); +}}; // Feature System v2 Stage 3: capabilities. A feature may `requires`/`provides` // abstract capabilities; the package may `provides` them at package level; and // the root [capabilities] table pins providers. -TEST(Manifest, CapabilitiesProvidesRequiresAndPins) { +UTest capabilities_provides_requires_and_pins{"Manifest/CapabilitiesProvidesRequiresAndPins", [] { constexpr auto src = R"( [package] name = "x" @@ -1230,25 +1214,25 @@ provide_lap = { provides = ["lapack"] } blas = "openblas" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); - ASSERT_EQ(m->provides.size(), 1u); - EXPECT_EQ(m->provides[0], "blas"); + expect(fatal(eq(m->provides.size(), 1u))); + expect(eq(m->provides[0], "blas")); - ASSERT_TRUE(m->featureRequires.contains("use_blas")); - ASSERT_EQ(m->featureRequires["use_blas"].size(), 1u); - EXPECT_EQ(m->featureRequires["use_blas"][0], "blas"); + expect(fatal(m->featureRequires.contains("use_blas"))); + expect(fatal(eq(m->featureRequires["use_blas"].size(), 1u))); + expect(eq(m->featureRequires["use_blas"][0], "blas")); - ASSERT_TRUE(m->featureProvides.contains("provide_lap")); - EXPECT_EQ(m->featureProvides["provide_lap"][0], "lapack"); + expect(fatal(m->featureProvides.contains("provide_lap"))); + expect(eq(m->featureProvides["provide_lap"][0], "lapack")); - ASSERT_TRUE(m->capabilityPins.contains("blas")); - EXPECT_EQ(m->capabilityPins["blas"], "openblas"); -} + expect(fatal(m->capabilityPins.contains("blas"))); + expect(eq(m->capabilityPins["blas"], "openblas")); +}}; // The Lua descriptor surface (index packages) parses package-level `provides` // and feature-scoped `requires`/`provides`/`defines`. -TEST(SynthesizeFromXpkgLua, CapabilitiesAndFeatureDefines) { +UTest capabilities_and_feature_defines{"SynthesizeFromXpkgLua/CapabilitiesAndFeatureDefines", [] { constexpr auto lua = R"( package = { spec = "1", @@ -1266,26 +1250,26 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "compat.eigen", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); - ASSERT_EQ(m->provides.size(), 1u); - EXPECT_EQ(m->provides[0], "blas"); + expect(fatal(eq(m->provides.size(), 1u))); + expect(eq(m->provides[0], "blas")); - ASSERT_TRUE(m->buildConfig.featureDefines.contains("use_blas")); - EXPECT_EQ(m->buildConfig.featureDefines["use_blas"][0], "EIGEN_USE_BLAS"); - ASSERT_TRUE(m->featureRequires.contains("use_blas")); - EXPECT_EQ(m->featureRequires["use_blas"][0], "blas"); - ASSERT_TRUE(m->featureProvides.contains("eigen_blas")); - EXPECT_EQ(m->featureProvides["eigen_blas"][0], "blas"); + expect(fatal(m->buildConfig.featureDefines.contains("use_blas"))); + expect(eq(m->buildConfig.featureDefines["use_blas"][0], "EIGEN_USE_BLAS")); + expect(fatal(m->featureRequires.contains("use_blas"))); + expect(eq(m->featureRequires["use_blas"][0], "blas")); + expect(fatal(m->featureProvides.contains("eigen_blas"))); + expect(eq(m->featureProvides["eigen_blas"][0], "blas")); // sources still gated as before. - ASSERT_TRUE(m->buildConfig.featureSources.contains("eigen_blas")); -} + expect(fatal(m->buildConfig.featureSources.contains("eigen_blas"))); +}}; // #253: a [features] table-form entry may carry `flags` — per-feature per-glob // compile flags, the same entry grammar as [build].flags. Parsed into // buildConfig.featureFlags keyed by feature; base globFlags stay untouched at // parse time (activation folds them in later, in prepare_build). -TEST(Manifest, FeatureFlagsTomlTableForm) { +UTest feature_flags_toml_table_form{"Manifest/FeatureFlagsTomlTableForm", [] { constexpr auto src = R"( [package] name = "x" @@ -1301,28 +1285,28 @@ simd = { sources = ["src/simd/**"], flags = [ { glob = "src/simd/**/*.neon.cpp", cxxflags = ["-mfpu=neon"] } ] } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); - ASSERT_TRUE(m->buildConfig.featureFlags.contains("simd")); + expect(fatal(m->buildConfig.featureFlags.contains("simd"))); auto& ff = m->buildConfig.featureFlags["simd"]; - ASSERT_EQ(ff.size(), 2u); // declaration order preserved - EXPECT_EQ(ff[0].glob, "src/simd/**/*.avx2.cpp"); - ASSERT_EQ(ff[0].cxxflags.size(), 1u); - EXPECT_EQ(ff[0].cxxflags[0], "-mavx2"); - ASSERT_EQ(ff[0].defines.size(), 1u); - EXPECT_EQ(ff[0].defines[0], "HAVE_AVX2"); - EXPECT_EQ(ff[1].glob, "src/simd/**/*.neon.cpp"); + expect(fatal(eq(ff.size(), 2u))); // declaration order preserved + expect(eq(ff[0].glob, "src/simd/**/*.avx2.cpp")); + expect(fatal(eq(ff[0].cxxflags.size(), 1u))); + expect(eq(ff[0].cxxflags[0], "-mavx2")); + expect(fatal(eq(ff[0].defines.size(), 1u))); + expect(eq(ff[0].defines[0], "HAVE_AVX2")); + expect(eq(ff[1].glob, "src/simd/**/*.neon.cpp")); // Parse-time base globFlags carry ONLY the [build].flags entry. - ASSERT_EQ(m->buildConfig.globFlags.size(), 1u); - EXPECT_EQ(m->buildConfig.globFlags[0].glob, "src/base/**"); + expect(fatal(eq(m->buildConfig.globFlags.size(), 1u))); + expect(eq(m->buildConfig.globFlags[0].glob, "src/base/**")); // Gated sources still land in featureSources alongside. - ASSERT_TRUE(m->buildConfig.featureSources.contains("simd")); -} + expect(fatal(m->buildConfig.featureSources.contains("simd"))); +}}; // #253: feature `flags` entries share [build].flags' closed grammar — a // missing `glob` and an unknown entry key are hard errors naming the // feature-scoped anchoring key. -TEST(Manifest, FeatureFlagsTomlErrors) { +UTest feature_flags_toml_errors{"Manifest/FeatureFlagsTomlErrors", [] { constexpr auto missingGlob = R"( [package] name = "x" @@ -1333,10 +1317,9 @@ kind = "lib" simd = { flags = [{ cxxflags = ["-mavx2"] }] } )"; auto m1 = mcpp::manifest::parse_string(missingGlob); - ASSERT_FALSE(m1.has_value()); - EXPECT_NE(m1.error().message.find("[features].simd.flags"), std::string::npos) - << m1.error().message; - EXPECT_NE(m1.error().message.find("glob"), std::string::npos); + expect(fatal(!m1.has_value())); + if (auto&& failed = expect(ne(m1.error().message.find("[features].simd.flags"), std::string::npos)); !failed) failed << m1.error().message; + expect(ne(m1.error().message.find("glob"), std::string::npos)); constexpr auto badKey = R"( [package] @@ -1348,15 +1331,14 @@ kind = "lib" simd = { flags = [{ glob = "src/**", ldflags = ["-lfoo"] }] } )"; auto m2 = mcpp::manifest::parse_string(badKey); - ASSERT_FALSE(m2.has_value()); - EXPECT_NE(m2.error().message.find("ldflags"), std::string::npos) - << m2.error().message; -} + expect(fatal(!m2.has_value())); + if (auto&& failed = expect(ne(m2.error().message.find("ldflags"), std::string::npos)); !failed) failed << m2.error().message; +}}; // #253 Lua descriptor surface: `features..flags` parses into // featureFlags (same entry grammar as the build-level `flags` key) and is a // KNOWN subfield — it must not land in xpkgUnknownKeys. -TEST(SynthesizeFromXpkgLua, FeatureFlagsParse) { +UTest feature_flags_parse{"SynthesizeFromXpkgLua/FeatureFlagsParse", [] { constexpr auto lua = R"( package = { spec = "1", @@ -1381,19 +1363,19 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "compat.opencv", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_TRUE(m->xpkgUnknownKeys.empty()); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(m->xpkgUnknownKeys.empty()); - ASSERT_TRUE(m->buildConfig.featureFlags.contains("dnn")); + expect(fatal(m->buildConfig.featureFlags.contains("dnn"))); auto& ff = m->buildConfig.featureFlags["dnn"]; - ASSERT_EQ(ff.size(), 2u); - EXPECT_EQ(ff[0].glob, "**/3rdparty/mlas/**"); - ASSERT_EQ(ff[0].defines.size(), 2u); - EXPECT_EQ(ff[0].defines[0], "BUILD_MLAS_NO_ONNXRUNTIME=1"); - EXPECT_EQ(ff[1].glob, "**/modules/dnn/**/*.avx2.cpp"); + expect(fatal(eq(ff.size(), 2u))); + expect(eq(ff[0].glob, "**/3rdparty/mlas/**")); + expect(fatal(eq(ff[0].defines.size(), 2u))); + expect(eq(ff[0].defines[0], "BUILD_MLAS_NO_ONNXRUNTIME=1")); + expect(eq(ff[1].glob, "**/modules/dnn/**/*.avx2.cpp")); // Base per-glob flags untouched at parse time. - ASSERT_EQ(m->buildConfig.globFlags.size(), 1u); - EXPECT_EQ(m->buildConfig.globFlags[0].glob, "*/core/*.cpp"); + expect(fatal(eq(m->buildConfig.globFlags.size(), 1u))); + expect(eq(m->buildConfig.globFlags[0].glob, "*/core/*.cpp")); // Unknown entry subkey is a hard error carrying the feature-scoped label. constexpr auto bad = R"( @@ -1408,17 +1390,16 @@ package = { } )"; auto mb = mcpp::manifest::synthesize_from_xpkg_lua(bad, "compat.opencv", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_FALSE(mb.has_value()); - EXPECT_NE(mb.error().message.find("features.dnn.flags"), std::string::npos) - << mb.error().message; -} + expect(fatal(!mb.has_value())); + if (auto&& failed = expect(ne(mb.error().message.find("features.dnn.flags"), std::string::npos)); !failed) failed << mb.error().message; +}}; // #253: per-OS descriptor sections participate in `features` via the same // textual-splice additive overlay every mcpp-segment key gets — same-named // features merge per-subkey by APPEND (base body first, OS body second), and a // per-OS section may register an OS-only feature. Locked here because the // common/delta pattern (neutral common payload + per-OS delta) depends on it. -TEST(SynthesizeFromXpkgLua, PerOsFeaturesAdditiveMerge) { +UTest per_os_features_additive_merge{"SynthesizeFromXpkgLua/PerOsFeaturesAdditiveMerge", [] { constexpr auto lua = R"( package = { spec = "1", @@ -1452,34 +1433,34 @@ package = { // linux leg: neutral payload + linux delta, macosx section invisible. auto ml = mcpp::manifest::synthesize_from_xpkg_lua( lua, "compat.opencv", "1.0.0", mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); - ASSERT_TRUE(ml.has_value()) << ml.error().format(); - ASSERT_TRUE(ml->buildConfig.featureSources.contains("dnn")); + if (auto&& failed = expect(fatal(ml.has_value())); !failed) failed << ml.error().format(); + expect(fatal(ml->buildConfig.featureSources.contains("dnn"))); { auto& srcs = ml->buildConfig.featureSources["dnn"]; - ASSERT_EQ(srcs.size(), 2u); // append order: neutral first, OS delta after - EXPECT_EQ(srcs[0], "*/modules/dnn/src/*.cpp"); - EXPECT_EQ(srcs[1], "*/3rdparty/mlas/lib/x86_64/*.S"); + expect(fatal(eq(srcs.size(), 2u))); // append order: neutral first, OS delta after + expect(eq(srcs[0], "*/modules/dnn/src/*.cpp")); + expect(eq(srcs[1], "*/3rdparty/mlas/lib/x86_64/*.S")); } - ASSERT_TRUE(ml->buildConfig.featureFlags.contains("dnn")); - EXPECT_EQ(ml->buildConfig.featureFlags["dnn"][0].glob, "**/3rdparty/mlas/**"); + expect(fatal(ml->buildConfig.featureFlags.contains("dnn"))); + expect(eq(ml->buildConfig.featureFlags["dnn"][0].glob, "**/3rdparty/mlas/**")); // OS-only feature registered (requestable) on this leg only. - EXPECT_TRUE(ml->featuresMap.contains("vaapi")); - ASSERT_TRUE(ml->buildConfig.featureDefines.contains("vaapi")); - EXPECT_EQ(ml->buildConfig.featureDefines["vaapi"][0], "HAVE_VAAPI"); + expect(ml->featuresMap.contains("vaapi")); + expect(fatal(ml->buildConfig.featureDefines.contains("vaapi"))); + expect(eq(ml->buildConfig.featureDefines["vaapi"][0], "HAVE_VAAPI")); // macosx leg: NEON delta instead, no mlas flags, no vaapi. auto mm = mcpp::manifest::synthesize_from_xpkg_lua( lua, "compat.opencv", "1.0.0", mcpp::toolchain::platform::TargetPlatform::for_lint_of("macosx")); - ASSERT_TRUE(mm.has_value()) << mm.error().format(); + if (auto&& failed = expect(fatal(mm.has_value())); !failed) failed << mm.error().format(); { auto& srcs = mm->buildConfig.featureSources["dnn"]; - ASSERT_EQ(srcs.size(), 2u); - EXPECT_EQ(srcs[0], "*/modules/dnn/src/*.cpp"); - EXPECT_EQ(srcs[1], "*/modules/dnn/neon/*.cpp"); + expect(fatal(eq(srcs.size(), 2u))); + expect(eq(srcs[0], "*/modules/dnn/src/*.cpp")); + expect(eq(srcs[1], "*/modules/dnn/neon/*.cpp")); } - EXPECT_FALSE(mm->buildConfig.featureFlags.contains("dnn")); - EXPECT_FALSE(mm->featuresMap.contains("vaapi")); -} + expect(!(mm->buildConfig.featureFlags.contains("dnn"))); + expect(!(mm->featuresMap.contains("vaapi"))); +}}; // Feature System v2 Stage 2a: optional deps activated by a feature. // TOML surface uses a dedicated [feature-deps.] section. @@ -1488,7 +1469,7 @@ package = { // form was in the documented `[feature-deps]` example, and because a fixture // only has to PARSE — nothing here ever asked the index for that package. It // reached a published package (`std-freestanding` 0.3.0) before it was measured. -TEST(Manifest, FeatureDepsTomlSection) { +UTest feature_deps_toml_section{"Manifest/FeatureDepsTomlSection", [] { constexpr auto src = R"( [package] name = "x" @@ -1502,15 +1483,15 @@ backend = [] zlib = "1.3" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_TRUE(m->featureDeps.contains("backend")); - EXPECT_EQ(m->featureDeps["backend"].size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(m->featureDeps.contains("backend"))); + expect(eq(m->featureDeps["backend"].size(), 1u)); // The optional dep is NOT in the always-on dependency set. - EXPECT_FALSE(m->dependencies.contains("zlib")); -} + expect(!(m->dependencies.contains("zlib"))); +}}; // Lua descriptor surface: a feature carries `deps` (nested table) + `implies`. -TEST(SynthesizeFromXpkgLua, FeatureDepsAndImplies) { +UTest feature_deps_and_implies{"SynthesizeFromXpkgLua/FeatureDepsAndImplies", [] { constexpr auto lua = R"( package = { spec = "1", @@ -1527,22 +1508,22 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "compat.eigen", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); // implies recorded in featuresMap - ASSERT_TRUE(m->featuresMap.contains("backend-openblas")); - ASSERT_EQ(m->featuresMap["backend-openblas"].size(), 1u); - EXPECT_EQ(m->featuresMap["backend-openblas"][0], "use_blas"); + expect(fatal(m->featuresMap.contains("backend-openblas"))); + expect(fatal(eq(m->featuresMap["backend-openblas"].size(), 1u))); + expect(eq(m->featuresMap["backend-openblas"][0], "use_blas")); // deps recorded in featureDeps, NOT in the always-on dependency set - ASSERT_TRUE(m->featureDeps.contains("backend-openblas")); - EXPECT_EQ(m->featureDeps["backend-openblas"].size(), 1u); - EXPECT_TRUE(m->dependencies.empty()); -} + expect(fatal(m->featureDeps.contains("backend-openblas"))); + expect(eq(m->featureDeps["backend-openblas"].size(), 1u)); + expect(m->dependencies.empty()); +}}; // Feature System v2 #243: dep/feat forwarding (Cargo parity). A `[features]` // implied-feature token containing '/' means "when this feature is active, // request from dependency ". It is split out of the // implies list into featureForwards; plain names stay implies. -TEST(Manifest, FeatureForwardTomlArrayAndImplies) { +UTest feature_forward_toml_array_and_implies{"Manifest/FeatureForwardTomlArrayAndImplies", [] { constexpr auto src = R"( [package] name = "x" @@ -1556,23 +1537,23 @@ dnn = ["dnn-local", "compat.opencv/dnn"] compat.opencv = { version = "0.0.3", default-features = false } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); // The plain name stays an implied feature. - ASSERT_TRUE(m->featuresMap.contains("dnn")); - ASSERT_EQ(m->featuresMap["dnn"].size(), 1u); - EXPECT_EQ(m->featuresMap["dnn"][0], "dnn-local"); + expect(fatal(m->featuresMap.contains("dnn"))); + expect(fatal(eq(m->featuresMap["dnn"].size(), 1u))); + expect(eq(m->featuresMap["dnn"][0], "dnn-local")); // The `dep/feat` token becomes a forward (depKey verbatim = stableMapKey). - ASSERT_TRUE(m->featureForwards.contains("dnn")); - ASSERT_EQ(m->featureForwards["dnn"].size(), 1u); - EXPECT_EQ(m->featureForwards["dnn"][0].first, "compat.opencv"); - EXPECT_EQ(m->featureForwards["dnn"][0].second, "dnn"); -} + expect(fatal(m->featureForwards.contains("dnn"))); + expect(fatal(eq(m->featureForwards["dnn"].size(), 1u))); + expect(eq(m->featureForwards["dnn"][0].first, "compat.opencv")); + expect(eq(m->featureForwards["dnn"][0].second, "dnn")); +}}; // The table form accepts forwards both mixed into `implies` and via the // dedicated self-documenting `forward` key; both feed featureForwards. -TEST(Manifest, FeatureForwardTableForms) { +UTest feature_forward_table_forms{"Manifest/FeatureForwardTableForms", [] { constexpr auto src = R"( [package] name = "x" @@ -1586,28 +1567,28 @@ b = { forward = ["dep.two/y", "dep.two/z"] } base = [] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); // `implies` keeps the plain name, forwards the slashed token. - ASSERT_EQ(m->featuresMap["a"].size(), 1u); - EXPECT_EQ(m->featuresMap["a"][0], "base"); - ASSERT_TRUE(m->featureForwards.contains("a")); - ASSERT_EQ(m->featureForwards["a"].size(), 1u); - EXPECT_EQ(m->featureForwards["a"][0].first, "dep.one"); - EXPECT_EQ(m->featureForwards["a"][0].second, "x"); + expect(fatal(eq(m->featuresMap["a"].size(), 1u))); + expect(eq(m->featuresMap["a"][0], "base")); + expect(fatal(m->featureForwards.contains("a"))); + expect(fatal(eq(m->featureForwards["a"].size(), 1u))); + expect(eq(m->featureForwards["a"][0].first, "dep.one")); + expect(eq(m->featureForwards["a"][0].second, "x")); // Dedicated `forward` key: all tokens are forwards; feature has no implies. - EXPECT_TRUE(m->featuresMap["b"].empty()); - ASSERT_TRUE(m->featureForwards.contains("b")); - ASSERT_EQ(m->featureForwards["b"].size(), 2u); - EXPECT_EQ(m->featureForwards["b"][0].first, "dep.two"); - EXPECT_EQ(m->featureForwards["b"][0].second, "y"); - EXPECT_EQ(m->featureForwards["b"][1].second, "z"); -} + expect(m->featuresMap["b"].empty()); + expect(fatal(m->featureForwards.contains("b"))); + expect(fatal(eq(m->featureForwards["b"].size(), 2u))); + expect(eq(m->featureForwards["b"][0].first, "dep.two")); + expect(eq(m->featureForwards["b"][0].second, "y")); + expect(eq(m->featureForwards["b"][1].second, "z")); +}}; // The Lua descriptor surface splits `dep/feat` tokens the same way (one shared // split point, two grammars). -TEST(SynthesizeFromXpkgLua, FeatureForwardParse) { +UTest feature_forward_parse{"SynthesizeFromXpkgLua/FeatureForwardParse", [] { constexpr auto lua = R"( package = { spec = "1", @@ -1623,29 +1604,29 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "opencv", "0.0.3", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); // The forward token left featuresMap for featureForwards. - EXPECT_TRUE(m->featuresMap["dnn"].empty()); - ASSERT_TRUE(m->featureForwards.contains("dnn")); - ASSERT_EQ(m->featureForwards["dnn"].size(), 1u); - EXPECT_EQ(m->featureForwards["dnn"][0].first, "compat.opencv"); - EXPECT_EQ(m->featureForwards["dnn"][0].second, "dnn"); + expect(m->featuresMap["dnn"].empty()); + expect(fatal(m->featureForwards.contains("dnn"))); + expect(fatal(eq(m->featureForwards["dnn"].size(), 1u))); + expect(eq(m->featureForwards["dnn"][0].first, "compat.opencv")); + expect(eq(m->featureForwards["dnn"][0].second, "dnn")); // sources still gated normally (untouched by the split). - ASSERT_TRUE(m->buildConfig.featureSources.contains("dnn")); -} + expect(fatal(m->buildConfig.featureSources.contains("dnn"))); +}}; // mcpp#237: an unknown mcpp-segment key is collected (so the build path can // surface it) and the did-you-mean helper maps well-known confusables + // edit-distance typos back to the closed key vocabulary. -TEST(XpkgUnknownKeys, CollectedAndDidYouMean) { +UTest collected_and_did_you_mean{"XpkgUnknownKeys/CollectedAndDidYouMean", [] { // `dependencies` is the canonical wrong spelling of `deps` (issue #237). - EXPECT_EQ(mcpp::manifest::closest_known_xpkg_key("dependencies"), "deps"); - EXPECT_EQ(mcpp::manifest::closest_known_xpkg_key("dependency"), "deps"); + expect(eq(mcpp::manifest::closest_known_xpkg_key("dependencies"), "deps")); + expect(eq(mcpp::manifest::closest_known_xpkg_key("dependency"), "deps")); // single-typo of a real key resolves via edit distance. - EXPECT_EQ(mcpp::manifest::closest_known_xpkg_key("cxxflag"), "cxxflags"); - EXPECT_EQ(mcpp::manifest::closest_known_xpkg_key("feature"), "features"); + expect(eq(mcpp::manifest::closest_known_xpkg_key("cxxflag"), "cxxflags")); + expect(eq(mcpp::manifest::closest_known_xpkg_key("feature"), "features")); // a genuinely unrelated key has no suggestion. - EXPECT_EQ(mcpp::manifest::closest_known_xpkg_key("wibble"), ""); + expect(eq(mcpp::manifest::closest_known_xpkg_key("wibble"), "")); // The build-path synthesizer records the unknown key rather than dropping // it silently. @@ -1661,16 +1642,16 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "x", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->xpkgUnknownKeys.size(), 1u); - EXPECT_EQ(m->xpkgUnknownKeys[0], "dependencies"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->xpkgUnknownKeys.size(), 1u))); + expect(eq(m->xpkgUnknownKeys[0], "dependencies")); +}}; // A feature's UNKNOWN sub-key (e.g. an unsupported `include_dirs`) is recorded // as `features..` instead of being silently swallowed — the // adoption-site warning (warn_unknown_xpkg_keys) then names it. Known sub-keys // keep parsing around it. -TEST(XpkgUnknownKeys, FeatureSubKeyRecorded) { +UTest feature_sub_key_recorded{"XpkgUnknownKeys/FeatureSubKeyRecorded", [] { constexpr auto lua = R"( package = { name = "x", @@ -1688,15 +1669,15 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "x", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->xpkgUnknownKeys.size(), 1u); - EXPECT_EQ(m->xpkgUnknownKeys[0], "features.opt.include_dirs"); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->xpkgUnknownKeys.size(), 1u))); + expect(eq(m->xpkgUnknownKeys[0], "features.opt.include_dirs")); // the known sub-key after the unknown one still parsed. - ASSERT_TRUE(m->buildConfig.featureDefines.contains("opt")); - EXPECT_EQ(m->buildConfig.featureDefines.at("opt").size(), 1u); -} + expect(fatal(m->buildConfig.featureDefines.contains("opt"))); + expect(eq(m->buildConfig.featureDefines.at("opt").size(), 1u)); +}}; -TEST(Manifest, BuildMacosDeploymentTarget) { +UTest build_macos_deployment_target{"Manifest/BuildMacosDeploymentTarget", [] { constexpr auto src = R"( [package] name = "x" @@ -1707,11 +1688,11 @@ macos_deployment_target = "11.0" kind = "lib" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->buildConfig.macosDeploymentTarget, "11.0"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->buildConfig.macosDeploymentTarget, "11.0")); +}}; -TEST(Manifest, BuildMacosDeploymentTargetDefaultsEmpty) { +UTest build_macos_deployment_target_defaults_empty{"Manifest/BuildMacosDeploymentTargetDefaultsEmpty", [] { constexpr auto src = R"( [package] name = "x" @@ -1720,11 +1701,11 @@ version = "0.1.0" kind = "lib" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_TRUE(m->buildConfig.macosDeploymentTarget.empty()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(m->buildConfig.macosDeploymentTarget.empty()); +}}; -TEST(Manifest, RuntimeConfig) { +UTest runtime_config{"Manifest/RuntimeConfig", [] { constexpr auto src = R"( [package] name = "x" @@ -1735,19 +1716,19 @@ dlopen_libs = ["libGLX.so.0", "libGL.so.1"] capabilities = ["x11.display", "opengl.glx.driver"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->runtimeConfig.libraryDirs.size(), 2u); - EXPECT_EQ(m->runtimeConfig.libraryDirs[0], "runtime/lib"); - EXPECT_EQ(m->runtimeConfig.libraryDirs[1], "plugins"); - ASSERT_EQ(m->runtimeConfig.dlopenLibs.size(), 2u); - EXPECT_EQ(m->runtimeConfig.dlopenLibs[0], "libGLX.so.0"); - EXPECT_EQ(m->runtimeConfig.dlopenLibs[1], "libGL.so.1"); - ASSERT_EQ(m->runtimeConfig.capabilities.size(), 2u); - EXPECT_EQ(m->runtimeConfig.capabilities[0], "x11.display"); - EXPECT_EQ(m->runtimeConfig.capabilities[1], "opengl.glx.driver"); -} - -TEST(SynthesizeFromXpkgLua, CflagsCxxflagsLdflagsAndCStandard) { + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->runtimeConfig.libraryDirs.size(), 2u))); + expect(eq(m->runtimeConfig.libraryDirs[0], "runtime/lib")); + expect(eq(m->runtimeConfig.libraryDirs[1], "plugins")); + expect(fatal(eq(m->runtimeConfig.dlopenLibs.size(), 2u))); + expect(eq(m->runtimeConfig.dlopenLibs[0], "libGLX.so.0")); + expect(eq(m->runtimeConfig.dlopenLibs[1], "libGL.so.1")); + expect(fatal(eq(m->runtimeConfig.capabilities.size(), 2u))); + expect(eq(m->runtimeConfig.capabilities[0], "x11.display")); + expect(eq(m->runtimeConfig.capabilities[1], "opengl.glx.driver")); +}}; + +UTest cflags_cxxflags_ldflags_and_c_standard{"SynthesizeFromXpkgLua/CflagsCxxflagsLdflagsAndCStandard", [] { constexpr auto src = R"( package = { spec = "1", @@ -1764,20 +1745,20 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->buildConfig.cflags.size(), 2u); - EXPECT_EQ(m->buildConfig.cflags[0], "-Wall"); - EXPECT_EQ(m->buildConfig.cflags[1], "-Dunused"); - ASSERT_EQ(m->buildConfig.cxxflags.size(), 1u); - EXPECT_EQ(m->buildConfig.cxxflags[0], "-Wextra"); - ASSERT_EQ(m->buildConfig.ldflags.size(), 1u); - EXPECT_EQ(m->buildConfig.ldflags[0], "-ltinyc"); - EXPECT_EQ(m->buildConfig.cStandard, "c11"); - ASSERT_EQ(m->modules.sources.size(), 1u); - EXPECT_EQ(m->modules.sources[0], "*/src/*.c"); -} - -TEST(SynthesizeFromXpkgLua, SharedTargetSoname) { + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->buildConfig.cflags.size(), 2u))); + expect(eq(m->buildConfig.cflags[0], "-Wall")); + expect(eq(m->buildConfig.cflags[1], "-Dunused")); + expect(fatal(eq(m->buildConfig.cxxflags.size(), 1u))); + expect(eq(m->buildConfig.cxxflags[0], "-Wextra")); + expect(fatal(eq(m->buildConfig.ldflags.size(), 1u))); + expect(eq(m->buildConfig.ldflags[0], "-ltinyc")); + expect(eq(m->buildConfig.cStandard, "c11")); + expect(fatal(eq(m->modules.sources.size(), 1u))); + expect(eq(m->modules.sources[0], "*/src/*.c")); +}}; + +UTest synthesize_from_xpkg_lua_shared_target_soname{"SynthesizeFromXpkgLua/SharedTargetSoname", [] { constexpr auto src = R"( package = { spec = "1", @@ -1790,13 +1771,13 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyshared", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets[0].kind, mcpp::manifest::Target::SharedLibrary); - EXPECT_EQ(m->targets[0].soname, "libtinyshared.so.1"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->targets.size(), 1u))); + expect(eq(m->targets[0].kind, mcpp::manifest::Target::SharedLibrary)); + expect(eq(m->targets[0].soname, "libtinyshared.so.1")); +}}; -TEST(SynthesizeFromXpkgLua, FeatureGatedSources) { +UTest feature_gated_sources{"SynthesizeFromXpkgLua/FeatureGatedSources", [] { // gtest-style: gtest_main.cc listed in base `sources` (old-mcpp compat) AND // under the `main` feature → featureSources records it; the feature is // registered in featuresMap. prepare_build later gates it (off by default). @@ -1815,17 +1796,17 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "gtestlike", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); // base sources keep both (old mcpp ignores `features` → no regression) - ASSERT_EQ(m->buildConfig.sources.size(), 2u); + expect(fatal(eq(m->buildConfig.sources.size(), 2u))); // the `main` feature is registered + carries its gated source - ASSERT_TRUE(m->featuresMap.contains("main")); - ASSERT_TRUE(m->buildConfig.featureSources.contains("main")); - ASSERT_EQ(m->buildConfig.featureSources.at("main").size(), 1u); - EXPECT_EQ(m->buildConfig.featureSources.at("main")[0], "*/src/main.cc"); -} + expect(fatal(m->featuresMap.contains("main"))); + expect(fatal(m->buildConfig.featureSources.contains("main"))); + expect(fatal(eq(m->buildConfig.featureSources.at("main").size(), 1u))); + expect(eq(m->buildConfig.featureSources.at("main")[0], "*/src/main.cc")); +}}; -TEST(SynthesizeFromXpkgLua, RuntimeConfig) { +UTest synthesize_from_xpkg_lua_runtime_config{"SynthesizeFromXpkgLua/RuntimeConfig", [] { constexpr auto src = R"( package = { spec = "1", @@ -1843,18 +1824,18 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "glfw", "3.4", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->runtimeConfig.libraryDirs.size(), 1u); - EXPECT_EQ(m->runtimeConfig.libraryDirs[0], "mcpp_generated/runtime/lib"); - ASSERT_EQ(m->runtimeConfig.dlopenLibs.size(), 2u); - EXPECT_EQ(m->runtimeConfig.dlopenLibs[0], "libGLX.so.0"); - EXPECT_EQ(m->runtimeConfig.dlopenLibs[1], "libGL.so.1"); - ASSERT_EQ(m->runtimeConfig.capabilities.size(), 2u); - EXPECT_EQ(m->runtimeConfig.capabilities[0], "x11.display"); - EXPECT_EQ(m->runtimeConfig.capabilities[1], "opengl.glx.driver"); -} - -TEST(SynthesizeFromXpkgLua, AppliesCurrentPlatformMcppOverlay) { + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->runtimeConfig.libraryDirs.size(), 1u))); + expect(eq(m->runtimeConfig.libraryDirs[0], "mcpp_generated/runtime/lib")); + expect(fatal(eq(m->runtimeConfig.dlopenLibs.size(), 2u))); + expect(eq(m->runtimeConfig.dlopenLibs[0], "libGLX.so.0")); + expect(eq(m->runtimeConfig.dlopenLibs[1], "libGL.so.1")); + expect(fatal(eq(m->runtimeConfig.capabilities.size(), 2u))); + expect(eq(m->runtimeConfig.capabilities[0], "x11.display")); + expect(eq(m->runtimeConfig.capabilities[1], "opengl.glx.driver")); +}}; + +UTest applies_current_platform_mcpp_overlay{"SynthesizeFromXpkgLua/AppliesCurrentPlatformMcppOverlay", [] { constexpr auto src = R"( package = { spec = "1", @@ -1888,7 +1869,7 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); std::string expectedSource = "*/src/linux.c"; std::string expectedInclude = "*/src/linux"; @@ -1906,31 +1887,31 @@ package = { expectedDep = "compat.win32"; } - ASSERT_EQ(m->modules.sources.size(), 2u); - EXPECT_EQ(m->modules.sources[0], "*/src/common.c"); - EXPECT_EQ(m->modules.sources[1], expectedSource); - ASSERT_EQ(m->buildConfig.includeDirs.size(), 2u); - EXPECT_EQ(m->buildConfig.includeDirs[0], "*/include"); - EXPECT_EQ(m->buildConfig.includeDirs[1], expectedInclude); - ASSERT_EQ(m->buildConfig.cflags.size(), 2u); - EXPECT_EQ(m->buildConfig.cflags[0], "-DCOMMON=1"); - EXPECT_EQ(m->buildConfig.cflags[1], expectedCflag); - EXPECT_EQ(m->dependencies.count("compat.base"), 1u); - EXPECT_EQ(m->dependencies.count(expectedDep), 1u); + expect(fatal(eq(m->modules.sources.size(), 2u))); + expect(eq(m->modules.sources[0], "*/src/common.c")); + expect(eq(m->modules.sources[1], expectedSource)); + expect(fatal(eq(m->buildConfig.includeDirs.size(), 2u))); + expect(eq(m->buildConfig.includeDirs[0], "*/include")); + expect(eq(m->buildConfig.includeDirs[1], expectedInclude)); + expect(fatal(eq(m->buildConfig.cflags.size(), 2u))); + expect(eq(m->buildConfig.cflags[0], "-DCOMMON=1")); + expect(eq(m->buildConfig.cflags[1], expectedCflag)); + expect(eq(m->dependencies.count("compat.base"), 1u)); + expect(eq(m->dependencies.count(expectedDep), 1u)); // osOverride seam (`mcpp xpkg parse --all-os`): a foreign OS section is // spliced on request, independent of the running host — the build path // (empty override) above stays host-selected. Reuses this fixture. auto win = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::toolchain::platform::TargetPlatform::for_lint_of("windows")); - ASSERT_TRUE(win.has_value()) << win.error().format(); - ASSERT_EQ(win->modules.sources.size(), 2u); - EXPECT_EQ(win->modules.sources[1], "*/src/win32.c"); - ASSERT_EQ(win->buildConfig.cflags.size(), 2u); - EXPECT_EQ(win->buildConfig.cflags[1], "-DWINDOWS=1"); - EXPECT_EQ(win->dependencies.count("compat.win32"), 1u); -} + if (auto&& failed = expect(fatal(win.has_value())); !failed) failed << win.error().format(); + expect(fatal(eq(win->modules.sources.size(), 2u))); + expect(eq(win->modules.sources[1], "*/src/win32.c")); + expect(fatal(eq(win->buildConfig.cflags.size(), 2u))); + expect(eq(win->buildConfig.cflags[1], "-DWINDOWS=1")); + expect(eq(win->dependencies.count("compat.win32"), 1u)); +}}; -TEST(SynthesizeFromXpkgLua, GeneratedFiles) { +UTest synthesize_from_xpkg_lua_generated_files{"SynthesizeFromXpkgLua/GeneratedFiles", [] { constexpr auto src = R"( package = { spec = "1", @@ -1947,17 +1928,17 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->buildConfig.generatedFiles.size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->buildConfig.generatedFiles.size(), 1u))); auto it = m->buildConfig.generatedFiles.find("mcpp_generated/include/config.h"); - ASSERT_NE(it, m->buildConfig.generatedFiles.end()); - EXPECT_EQ(it->second, "#pragma once\n#define TINYC_OK 1\n"); -} + expect(fatal(ne(it, m->buildConfig.generatedFiles.end()))); + expect(eq(it->second, "#pragma once\n#define TINYC_OK 1\n")); +}}; // #249: the xpkg descriptor body accepts `include_dirs_after` alongside // `include_dirs` and keeps the two channels separate (after-dirs are never // upgraded to -I). -TEST(SynthesizeFromXpkgLua, IncludeDirsAfter) { +UTest synthesize_from_xpkg_lua_include_dirs_after{"SynthesizeFromXpkgLua/IncludeDirsAfter", [] { constexpr auto src = R"( package = { spec = "1", @@ -1972,15 +1953,15 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tinyc", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->buildConfig.includeDirs.size(), 1u); - EXPECT_EQ(m->buildConfig.includeDirs[0], "*/include"); - ASSERT_EQ(m->buildConfig.includeDirsAfter.size(), 1u); - EXPECT_EQ(m->buildConfig.includeDirsAfter[0], "*"); - EXPECT_TRUE(m->xpkgUnknownKeys.empty()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->buildConfig.includeDirs.size(), 1u))); + expect(eq(m->buildConfig.includeDirs[0], "*/include")); + expect(fatal(eq(m->buildConfig.includeDirsAfter.size(), 1u))); + expect(eq(m->buildConfig.includeDirsAfter[0], "*")); + expect(m->xpkgUnknownKeys.empty()); +}}; -TEST(Manifest, DependenciesFlatDefaultNamespace) { +UTest dependencies_flat_default_namespace{"Manifest/DependenciesFlatDefaultNamespace", [] { constexpr auto src = R"( [package] name = "x" @@ -1990,19 +1971,19 @@ gtest = "1.15.2" foo = { path = "../foo" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->dependencies.size(), 2u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->dependencies.size(), 2u))); auto& g = m->dependencies.at("gtest"); - EXPECT_EQ(g.namespace_, "mcpplibs"); - EXPECT_EQ(g.shortName, "gtest"); - EXPECT_EQ(g.version, "1.15.2"); + expect(eq(g.namespace_, "mcpplibs")); + expect(eq(g.shortName, "gtest")); + expect(eq(g.version, "1.15.2")); auto& f = m->dependencies.at("foo"); - EXPECT_EQ(f.namespace_, "mcpplibs"); - EXPECT_EQ(f.shortName, "foo"); - EXPECT_EQ(f.path, "../foo"); -} + expect(eq(f.namespace_, "mcpplibs")); + expect(eq(f.shortName, "foo")); + expect(eq(f.path, "../foo")); +}}; -TEST(Manifest, DependenciesNamespacedSubtable) { +UTest dependencies_namespaced_subtable{"Manifest/DependenciesNamespacedSubtable", [] { constexpr auto src = R"( [package] name = "x" @@ -2016,27 +1997,27 @@ templates = { version = "0.0.1" } gtest = "1.15.2" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->dependencies.size(), 3u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->dependencies.size(), 3u))); auto& cmdline = m->dependencies.at("mcpplibs.cmdline"); - EXPECT_EQ(cmdline.namespace_, "mcpplibs"); - EXPECT_EQ(cmdline.shortName, "cmdline"); - EXPECT_EQ(cmdline.version, "0.0.2"); - EXPECT_FALSE(cmdline.legacyDottedKey); + expect(eq(cmdline.namespace_, "mcpplibs")); + expect(eq(cmdline.shortName, "cmdline")); + expect(eq(cmdline.version, "0.0.2")); + expect(!cmdline.legacyDottedKey); auto& tmpl = m->dependencies.at("mcpplibs.templates"); - EXPECT_EQ(tmpl.namespace_, "mcpplibs"); - EXPECT_EQ(tmpl.shortName, "templates"); - EXPECT_EQ(tmpl.version, "0.0.1"); + expect(eq(tmpl.namespace_, "mcpplibs")); + expect(eq(tmpl.shortName, "templates")); + expect(eq(tmpl.version, "0.0.1")); auto& gtest = m->dependencies.at("gtest"); - EXPECT_EQ(gtest.namespace_, "mcpplibs"); - EXPECT_EQ(gtest.shortName, "gtest"); - EXPECT_EQ(gtest.version, "1.15.2"); -} + expect(eq(gtest.namespace_, "mcpplibs")); + expect(eq(gtest.shortName, "gtest")); + expect(eq(gtest.version, "1.15.2")); +}}; -TEST(Manifest, DependenciesLegacyDottedKeyStillParsed) { +UTest dependencies_legacy_dotted_key_still_parsed{"Manifest/DependenciesLegacyDottedKeyStillParsed", [] { // Pre-namespace-aware mcpp.toml: quoted dotted key. constexpr auto src = R"( [package] @@ -2047,16 +2028,16 @@ version = "0.1.0" "mcpplibs.cmdline" = "0.0.2" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->dependencies.size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->dependencies.size(), 1u))); auto& s = m->dependencies.at("mcpplibs.cmdline"); - EXPECT_EQ(s.namespace_, "mcpplibs"); - EXPECT_EQ(s.shortName, "cmdline"); - EXPECT_EQ(s.version, "0.0.2"); - EXPECT_TRUE(s.legacyDottedKey); -} + expect(eq(s.namespace_, "mcpplibs")); + expect(eq(s.shortName, "cmdline")); + expect(eq(s.version, "0.0.2")); + expect(bool(s.legacyDottedKey)); +}}; -TEST(Manifest, DependenciesDottedSelectorIsOneExactIdentity) { +UTest dependencies_dotted_selector_is_one_exact_identity{"Manifest/DependenciesDottedSelectorIsOneExactIdentity", [] { constexpr auto src = R"( [package] name = "x" @@ -2066,21 +2047,21 @@ version = "0.1.0" capi.lua = "0.0.3" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->dependencies.size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->dependencies.size(), 1u))); auto& s = m->dependencies.at("capi.lua"); - EXPECT_EQ(s.namespace_, "capi"); - EXPECT_EQ(s.shortName, "lua"); - EXPECT_EQ(s.version, "0.0.3"); - EXPECT_FALSE(s.legacyDottedKey); - EXPECT_TRUE(s.legacyCandidateSearch); - ASSERT_EQ(s.candidates.size(), 1u); - EXPECT_EQ(s.candidates[0].namespace_, "capi"); - EXPECT_EQ(s.candidates[0].shortName, "lua"); -} - -TEST(Manifest, DependencySelectorsRejectUnsafeOrAmbiguousSegments) { + expect(eq(s.namespace_, "capi")); + expect(eq(s.shortName, "lua")); + expect(eq(s.version, "0.0.3")); + expect(!s.legacyDottedKey); + expect(bool(s.legacyCandidateSearch)); + expect(fatal(eq(s.candidates.size(), 1u))); + expect(eq(s.candidates[0].namespace_, "capi")); + expect(eq(s.candidates[0].shortName, "lua")); +}}; + +UTest dependency_selectors_reject_unsafe_or_ambiguous_segments{"Manifest/DependencySelectorsRejectUnsafeOrAmbiguousSegments", [] { const std::vector invalid{ "acme/widget", "acme widget", "acme[widget", "acme=widget", "acme#widget", "acme..widget", @@ -2094,13 +2075,12 @@ version = "0.1.0" "{}" = "1.0.0" )", selector); auto m = mcpp::manifest::parse_string(src); - EXPECT_FALSE(m.has_value()) << selector; - if (!m) EXPECT_NE(m.error().message.find("invalid package selector"), - std::string::npos) << m.error().format(); + expect(!m.has_value()) << selector; + if (!m) expect(ne(m.error().message.find("invalid package selector"), std::string::npos)) << m.error().format(); } -} +}}; -TEST(Manifest, DependenciesNamespacedSubtableNestedDottedKeyIsCanonical) { +UTest dependencies_namespaced_subtable_nested_dotted_key_is_canonical{"Manifest/DependenciesNamespacedSubtableNestedDottedKeyIsCanonical", [] { constexpr auto src = R"( [package] name = "x" @@ -2110,21 +2090,21 @@ version = "0.1.0" capi.lua = "0.0.3" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->dependencies.size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->dependencies.size(), 1u))); auto& s = m->dependencies.at("mcpplibs.capi.lua"); - EXPECT_EQ(s.namespace_, "mcpplibs.capi"); - EXPECT_EQ(s.shortName, "lua"); - EXPECT_EQ(s.version, "0.0.3"); - EXPECT_FALSE(s.legacyDottedKey); - EXPECT_FALSE(s.legacyCandidateSearch); - ASSERT_EQ(s.candidates.size(), 1u); - EXPECT_EQ(s.candidates[0].namespace_, "mcpplibs.capi"); - EXPECT_EQ(s.candidates[0].shortName, "lua"); -} - -TEST(Manifest, DependenciesInlineSpecCoexistsWithSubtable) { + expect(eq(s.namespace_, "mcpplibs.capi")); + expect(eq(s.shortName, "lua")); + expect(eq(s.version, "0.0.3")); + expect(!s.legacyDottedKey); + expect(!s.legacyCandidateSearch); + expect(fatal(eq(s.candidates.size(), 1u))); + expect(eq(s.candidates[0].namespace_, "mcpplibs.capi")); + expect(eq(s.candidates[0].shortName, "lua")); +}}; + +UTest dependencies_inline_spec_coexists_with_subtable{"Manifest/DependenciesInlineSpecCoexistsWithSubtable", [] { // `bar = { git = "...", tag = "..." }` looks like a subtable but has // only dep-spec keys → treated as inline spec under default ns. // `[dependencies.acme]` is a real namespace subtable. @@ -2140,22 +2120,22 @@ bar = { git = "https://example.com/bar.git", tag = "v1" } util = "2.0.0" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->dependencies.size(), 2u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->dependencies.size(), 2u))); auto& bar = m->dependencies.at("bar"); - EXPECT_EQ(bar.namespace_, "mcpplibs"); - EXPECT_EQ(bar.shortName, "bar"); - EXPECT_EQ(bar.git, "https://example.com/bar.git"); - EXPECT_EQ(bar.gitRev, "v1"); - EXPECT_EQ(bar.gitRefKind, "tag"); + expect(eq(bar.namespace_, "mcpplibs")); + expect(eq(bar.shortName, "bar")); + expect(eq(bar.git, "https://example.com/bar.git")); + expect(eq(bar.gitRev, "v1")); + expect(eq(bar.gitRefKind, "tag")); auto& util = m->dependencies.at("acme.util"); - EXPECT_EQ(util.namespace_, "acme"); - EXPECT_EQ(util.shortName, "util"); - EXPECT_EQ(util.version, "2.0.0"); -} + expect(eq(util.namespace_, "acme")); + expect(eq(util.shortName, "util")); + expect(eq(util.version, "2.0.0")); +}}; -TEST(SynthesizeFromXpkgLua, DepsKeySplitNamespace) { +UTest deps_key_split_namespace{"SynthesizeFromXpkgLua/DepsKeySplitNamespace", [] { constexpr auto src = R"( package = { spec = "1", @@ -2172,21 +2152,21 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "consumer", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->dependencies.size(), 2u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->dependencies.size(), 2u))); auto& a = m->dependencies.at("mbedtls"); - EXPECT_EQ(a.namespace_, "mcpplibs"); - EXPECT_EQ(a.shortName, "mbedtls"); - EXPECT_EQ(a.version, "3.6.1"); + expect(eq(a.namespace_, "mcpplibs")); + expect(eq(a.shortName, "mbedtls")); + expect(eq(a.version, "3.6.1")); auto& b = m->dependencies.at("mcpplibs.cmdline"); - EXPECT_EQ(b.namespace_, "mcpplibs"); - EXPECT_EQ(b.shortName, "cmdline"); - EXPECT_EQ(b.version, "0.0.2"); -} + expect(eq(b.namespace_, "mcpplibs")); + expect(eq(b.shortName, "cmdline")); + expect(eq(b.version, "0.0.2")); +}}; -TEST(SynthesizeFromXpkgLua, DepsDottedSelectorsAreExact) { +UTest deps_dotted_selectors_are_exact{"SynthesizeFromXpkgLua/DepsDottedSelectorsAreExact", [] { constexpr auto src = R"( package = { spec = "1", @@ -2204,38 +2184,38 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "consumer", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->dependencies.size(), 3u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->dependencies.size(), 3u))); auto& lua = m->dependencies.at("capi.lua"); - EXPECT_EQ(lua.namespace_, "capi"); - EXPECT_EQ(lua.shortName, "lua"); - EXPECT_EQ(lua.version, "0.0.3"); - EXPECT_TRUE(lua.legacyCandidateSearch); - ASSERT_EQ(lua.candidates.size(), 1u); - EXPECT_EQ(lua.candidates[0].namespace_, "capi"); - EXPECT_EQ(lua.candidates[0].shortName, "lua"); + expect(eq(lua.namespace_, "capi")); + expect(eq(lua.shortName, "lua")); + expect(eq(lua.version, "0.0.3")); + expect(bool(lua.legacyCandidateSearch)); + expect(fatal(eq(lua.candidates.size(), 1u))); + expect(eq(lua.candidates[0].namespace_, "capi")); + expect(eq(lua.candidates[0].shortName, "lua")); auto& imgui = m->dependencies.at("imgui.core"); - EXPECT_EQ(imgui.namespace_, "imgui"); - EXPECT_EQ(imgui.shortName, "core"); - EXPECT_EQ(imgui.version, "0.0.1"); - EXPECT_TRUE(imgui.legacyCandidateSearch); - ASSERT_EQ(imgui.candidates.size(), 1u); - EXPECT_EQ(imgui.candidates[0].namespace_, "imgui"); - EXPECT_EQ(imgui.candidates[0].shortName, "core"); + expect(eq(imgui.namespace_, "imgui")); + expect(eq(imgui.shortName, "core")); + expect(eq(imgui.version, "0.0.1")); + expect(bool(imgui.legacyCandidateSearch)); + expect(fatal(eq(imgui.candidates.size(), 1u))); + expect(eq(imgui.candidates[0].namespace_, "imgui")); + expect(eq(imgui.candidates[0].shortName, "core")); auto& gtest = m->dependencies.at("compat.gtest"); - EXPECT_EQ(gtest.namespace_, "compat"); - EXPECT_EQ(gtest.shortName, "gtest"); - EXPECT_EQ(gtest.version, "1.15.2"); - EXPECT_TRUE(gtest.legacyCandidateSearch); - ASSERT_EQ(gtest.candidates.size(), 1u); - EXPECT_EQ(gtest.candidates[0].namespace_, "compat"); - EXPECT_EQ(gtest.candidates[0].shortName, "gtest"); -} + expect(eq(gtest.namespace_, "compat")); + expect(eq(gtest.shortName, "gtest")); + expect(eq(gtest.version, "1.15.2")); + expect(bool(gtest.legacyCandidateSearch)); + expect(fatal(eq(gtest.candidates.size(), 1u))); + expect(eq(gtest.candidates[0].namespace_, "compat")); + expect(eq(gtest.candidates[0].shortName, "gtest")); +}}; -TEST(SynthesizeFromXpkgLua, InvalidDependencySelectorIsAnError) { +UTest invalid_dependency_selector_is_an_error{"SynthesizeFromXpkgLua/InvalidDependencySelectorIsAnError", [] { constexpr auto src = R"( package = { spec = "1", @@ -2249,12 +2229,11 @@ package = { )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua( src, "consumer", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("invalid package selector"), - std::string::npos) << m.error().format(); -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("invalid package selector"), std::string::npos)); !failed) failed << m.error().format(); +}}; -TEST(Manifest, WorkspaceSectionParsed) { +UTest workspace_section_parsed{"Manifest/WorkspaceSectionParsed", [] { constexpr auto src = R"( [workspace] members = ["libs/core", "libs/http", "apps/server"] @@ -2268,21 +2247,21 @@ gtest = "1.15.2" mbedtls = "3.6.1" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_TRUE(m->workspace.present); - ASSERT_EQ(m->workspace.members.size(), 3u); - EXPECT_EQ(m->workspace.members[0], "libs/core"); - EXPECT_EQ(m->workspace.members[1], "libs/http"); - EXPECT_EQ(m->workspace.members[2], "apps/server"); - ASSERT_EQ(m->workspace.exclude.size(), 1u); - EXPECT_EQ(m->workspace.exclude[0], "libs/experimental"); - ASSERT_EQ(m->workspace.dependencies.size(), 3u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(bool(m->workspace.present)); + expect(fatal(eq(m->workspace.members.size(), 3u))); + expect(eq(m->workspace.members[0], "libs/core")); + expect(eq(m->workspace.members[1], "libs/http")); + expect(eq(m->workspace.members[2], "apps/server")); + expect(fatal(eq(m->workspace.exclude.size(), 1u))); + expect(eq(m->workspace.exclude[0], "libs/experimental")); + expect(fatal(eq(m->workspace.dependencies.size(), 3u))); auto& gt = m->workspace.dependencies.at("compat.gtest"); - EXPECT_EQ(gt.version, "1.15.2"); - EXPECT_EQ(gt.namespace_, "compat"); -} + expect(eq(gt.version, "1.15.2")); + expect(eq(gt.namespace_, "compat")); +}}; -TEST(Manifest, WorkspaceDependenciesUseExactDottedSelectors) { +UTest workspace_dependencies_use_exact_dotted_selectors{"Manifest/WorkspaceDependenciesUseExactDottedSelectors", [] { constexpr auto src = R"( [workspace] members = ["libs/core"] @@ -2294,47 +2273,47 @@ compat.gtest = "1.15.2" mcpplibs.templates = "0.0.1" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->workspace.dependencies.size(), 4u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->workspace.dependencies.size(), 4u))); auto& lua = m->workspace.dependencies.at("capi.lua"); - EXPECT_EQ(lua.namespace_, "capi"); - EXPECT_EQ(lua.shortName, "lua"); - EXPECT_EQ(lua.version, "0.0.3"); - EXPECT_TRUE(lua.legacyCandidateSearch); - ASSERT_EQ(lua.candidates.size(), 1u); - EXPECT_EQ(lua.candidates[0].namespace_, "capi"); - EXPECT_EQ(lua.candidates[0].shortName, "lua"); + expect(eq(lua.namespace_, "capi")); + expect(eq(lua.shortName, "lua")); + expect(eq(lua.version, "0.0.3")); + expect(bool(lua.legacyCandidateSearch)); + expect(fatal(eq(lua.candidates.size(), 1u))); + expect(eq(lua.candidates[0].namespace_, "capi")); + expect(eq(lua.candidates[0].shortName, "lua")); auto& imgui = m->workspace.dependencies.at("imgui.core"); - EXPECT_EQ(imgui.namespace_, "imgui"); - EXPECT_EQ(imgui.shortName, "core"); - EXPECT_EQ(imgui.version, "0.0.1"); - EXPECT_TRUE(imgui.legacyCandidateSearch); - ASSERT_EQ(imgui.candidates.size(), 1u); - EXPECT_EQ(imgui.candidates[0].namespace_, "imgui"); - EXPECT_EQ(imgui.candidates[0].shortName, "core"); + expect(eq(imgui.namespace_, "imgui")); + expect(eq(imgui.shortName, "core")); + expect(eq(imgui.version, "0.0.1")); + expect(bool(imgui.legacyCandidateSearch)); + expect(fatal(eq(imgui.candidates.size(), 1u))); + expect(eq(imgui.candidates[0].namespace_, "imgui")); + expect(eq(imgui.candidates[0].shortName, "core")); auto& gt = m->workspace.dependencies.at("compat.gtest"); - EXPECT_EQ(gt.namespace_, "compat"); - EXPECT_EQ(gt.shortName, "gtest"); - EXPECT_EQ(gt.version, "1.15.2"); - EXPECT_TRUE(gt.legacyCandidateSearch); - ASSERT_EQ(gt.candidates.size(), 1u); - EXPECT_EQ(gt.candidates[0].namespace_, "compat"); - EXPECT_EQ(gt.candidates[0].shortName, "gtest"); + expect(eq(gt.namespace_, "compat")); + expect(eq(gt.shortName, "gtest")); + expect(eq(gt.version, "1.15.2")); + expect(bool(gt.legacyCandidateSearch)); + expect(fatal(eq(gt.candidates.size(), 1u))); + expect(eq(gt.candidates[0].namespace_, "compat")); + expect(eq(gt.candidates[0].shortName, "gtest")); auto& tmpl = m->workspace.dependencies.at("mcpplibs.templates"); - EXPECT_EQ(tmpl.namespace_, "mcpplibs"); - EXPECT_EQ(tmpl.shortName, "templates"); - EXPECT_EQ(tmpl.version, "0.0.1"); - EXPECT_FALSE(tmpl.legacyCandidateSearch); - ASSERT_EQ(tmpl.candidates.size(), 1u); - EXPECT_EQ(tmpl.candidates[0].namespace_, "mcpplibs"); - EXPECT_EQ(tmpl.candidates[0].shortName, "templates"); -} + expect(eq(tmpl.namespace_, "mcpplibs")); + expect(eq(tmpl.shortName, "templates")); + expect(eq(tmpl.version, "0.0.1")); + expect(!tmpl.legacyCandidateSearch); + expect(fatal(eq(tmpl.candidates.size(), 1u))); + expect(eq(tmpl.candidates[0].namespace_, "mcpplibs")); + expect(eq(tmpl.candidates[0].shortName, "templates")); +}}; -TEST(Manifest, WorkspaceTrueInDependency) { +UTest workspace_true_in_dependency{"Manifest/WorkspaceTrueInDependency", [] { constexpr auto src = R"( [package] name = "x" @@ -2343,14 +2322,14 @@ version = "0.1.0" mbedtls = { workspace = true } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto& s = m->dependencies.at("compat.mbedtls"); - EXPECT_TRUE(s.inheritWorkspace); - EXPECT_EQ(s.namespace_, "compat"); - EXPECT_EQ(s.shortName, "mbedtls"); -} + expect(bool(s.inheritWorkspace)); + expect(eq(s.namespace_, "compat")); + expect(eq(s.shortName, "mbedtls")); +}}; -TEST(Manifest, IndicesDefaultKeyNormalizesToDefaultNamespace) { +UTest indices_default_key_normalizes_to_default_namespace{"Manifest/IndicesDefaultKeyNormalizesToDefaultNamespace", [] { // R6: `[indices] default = {...}` (the canonical spelling) is a // redirect for the DEFAULT namespace (bare `gtest = "1.15.2"` deps), // not a literal index named "default". It must be stored under @@ -2368,17 +2347,16 @@ sources = ["src/**/*.cppm"] default = { path = "../local-pkgs" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto it = m->indices.find(std::string(mcpp::manifest::kDefaultNamespace)); - ASSERT_NE(it, m->indices.end()) - << "[indices] default = {...} must normalize to kDefaultNamespace"; - EXPECT_EQ(it->second.path.string(), "../local-pkgs"); - EXPECT_FALSE(it->second.is_builtin()); - EXPECT_EQ(m->indices.find("default"), m->indices.end()) + if (auto&& failed = expect(fatal(ne(it, m->indices.end()))); !failed) failed << "[indices] default = {...} must normalize to kDefaultNamespace"; + expect(eq(it->second.path.string(), "../local-pkgs")); + expect(!(it->second.is_builtin())); + expect(eq(m->indices.find("default"), m->indices.end())) << "the literal key \"default\" must not survive normalization"; -} +}}; -TEST(Manifest, IndicesEmptyStringKeyNormalizesToDefaultNamespace) { +UTest indices_empty_string_key_normalizes_to_default_namespace{"Manifest/IndicesEmptyStringKeyNormalizesToDefaultNamespace", [] { // The empty-quoted key `""` is also accepted as a spelling of the // default-namespace redirect. constexpr auto src = R"( @@ -2393,14 +2371,13 @@ sources = ["src/**/*.cppm"] "" = { path = "../local-pkgs" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto it = m->indices.find(std::string(mcpp::manifest::kDefaultNamespace)); - ASSERT_NE(it, m->indices.end()) - << "[indices] \"\" = {...} must normalize to kDefaultNamespace"; - EXPECT_EQ(it->second.path.string(), "../local-pkgs"); -} + if (auto&& failed = expect(fatal(ne(it, m->indices.end()))); !failed) failed << "[indices] \"\" = {...} must normalize to kDefaultNamespace"; + expect(eq(it->second.path.string(), "../local-pkgs")); +}}; -TEST(Manifest, IndicesDefaultKeyWithUrlIsRejected) { +UTest indices_default_key_with_url_is_rejected{"Manifest/IndicesDefaultKeyWithUrlIsRejected", [] { // R6's design goal is redirecting the default namespace to a LOCAL // index checkout (`path = ...`), so the index repo can test module // packages. The `url` form is not a design requirement, and silently @@ -2422,15 +2399,13 @@ sources = ["src/**/*.cppm"] default = { url = "https://example.com/mcpp-index.git" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()) - << "[indices] default = { url = ... } must be rejected, not silently " + if (auto&& failed = expect(fatal(!m.has_value())); !failed) failed << "[indices] default = { url = ... } must be rejected, not silently " "accepted as a no-op redirect"; - EXPECT_NE(m.error().format().find("path"), std::string::npos) - << "error should mention that only 'path' is supported for the " + if (auto&& failed = expect(ne(m.error().format().find("path"), std::string::npos)); !failed) failed << "error should mention that only 'path' is supported for the " "default-namespace redirect; got: " << m.error().format(); -} +}}; -TEST(Manifest, IndicesMcpplibsUrlPinStillAccepted) { +UTest indices_mcpplibs_url_pin_still_accepted{"Manifest/IndicesMcpplibsUrlPinStillAccepted", [] { // A literal `[indices] mcpplibs = { url = ..., rev = ... }` is a // DIFFERENT key spelling from the `default`/`""` alias — it pins the // real builtin registry to a commit, not a default-namespace redirect @@ -2448,15 +2423,15 @@ sources = ["src/**/*.cppm"] mcpplibs = { url = "https://example.com/mcpp-index.git", rev = "abc123" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto it = m->indices.find(std::string(mcpp::manifest::kDefaultNamespace)); - ASSERT_NE(it, m->indices.end()); - EXPECT_EQ(it->second.url, "https://example.com/mcpp-index.git"); - EXPECT_EQ(it->second.rev, "abc123"); - EXPECT_TRUE(it->second.is_builtin()); -} + expect(fatal(ne(it, m->indices.end()))); + expect(eq(it->second.url, "https://example.com/mcpp-index.git")); + expect(eq(it->second.rev, "abc123")); + expect(it->second.is_builtin()); +}}; -TEST(Manifest, IndicesDefaultAndMcpplibsCollisionRejected) { +UTest indices_default_and_mcpplibs_collision_rejected{"Manifest/IndicesDefaultAndMcpplibsCollisionRejected", [] { // `default`, `""`, and a literal `mcpplibs` key all normalize to the // same map slot (kDefaultNamespace). Declaring more than one must be a // parse error, not a silent last-writer-wins clobber. @@ -2473,14 +2448,12 @@ mcpplibs = { path = "../local-pkgs-a" } default = { path = "../local-pkgs-b" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()) - << "[indices] mcpplibs + default together must be rejected as a " + if (auto&& failed = expect(fatal(!m.has_value())); !failed) failed << "[indices] mcpplibs + default together must be rejected as a " "collision on the same default-namespace slot"; - EXPECT_NE(m.error().format().find("collide"), std::string::npos) - << "error should mention the collision; got: " << m.error().format(); -} + if (auto&& failed = expect(ne(m.error().format().find("collide"), std::string::npos)); !failed) failed << "error should mention the collision; got: " << m.error().format(); +}}; -TEST(Manifest, WorkspaceDependenciesPathFormFlatKey) { +UTest workspace_dependencies_path_form_flat_key{"Manifest/WorkspaceDependenciesPathFormFlatKey", [] { // #224: `[workspace.dependencies] ylib = { path = "ylib" }` — a plain // (non-namespaced, non-dotted) top-level key with an inline path table // — must parse to a path-form DependencySpec, not get misrouted into @@ -2494,26 +2467,26 @@ members = ["a"] ylib = { path = "ylib" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_TRUE(m->workspace.dependencies.contains("ylib")) + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(m->workspace.dependencies.contains("ylib"))) << "workspace.dependencies must contain a flat 'ylib' key"; auto& s = m->workspace.dependencies.at("ylib"); - EXPECT_EQ(s.path, "ylib"); - EXPECT_TRUE(s.version.empty()); -} + expect(eq(s.path, "ylib")); + expect(s.version.empty()); +}}; -TEST(Manifest, NoWorkspaceSectionMeansNotPresent) { +UTest no_workspace_section_means_not_present{"Manifest/NoWorkspaceSectionMeansNotPresent", [] { constexpr auto src = R"( [package] name = "x" version = "0.1.0" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()); - EXPECT_FALSE(m->workspace.present); -} + expect(fatal(m.has_value())); + expect(!(m->workspace.present)); +}}; -TEST(Manifest, LibRootInferredFromPackageName) { +UTest lib_root_inferred_from_package_name{"Manifest/LibRootInferredFromPackageName", [] { constexpr auto src = R"( [package] name = "mcpplibs.tinyhttps" @@ -2522,14 +2495,14 @@ version = "0.2.0" kind = "lib" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_TRUE(m->lib.path.empty()); - EXPECT_TRUE(mcpp::manifest::has_lib_target(*m)); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(m->lib.path.empty()); + expect(mcpp::manifest::has_lib_target(*m)); auto root = mcpp::manifest::resolve_lib_root_path(*m); - EXPECT_EQ(root, std::filesystem::path("src/tinyhttps.cppm")); -} + expect(eq(root, std::filesystem::path("src/tinyhttps.cppm"))); +}}; -TEST(Manifest, LibRootBareNameNoNamespace) { +UTest lib_root_bare_name_no_namespace{"Manifest/LibRootBareNameNoNamespace", [] { constexpr auto src = R"( [package] name = "gtest" @@ -2538,12 +2511,12 @@ version = "1.0.0" kind = "lib" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto root = mcpp::manifest::resolve_lib_root_path(*m); - EXPECT_EQ(root, std::filesystem::path("src/gtest.cppm")); -} + expect(eq(root, std::filesystem::path("src/gtest.cppm"))); +}}; -TEST(Manifest, LibRootExplicitOverride) { +UTest lib_root_explicit_override{"Manifest/LibRootExplicitOverride", [] { constexpr auto src = R"( [package] name = "mcpplibs.tinyhttps" @@ -2554,13 +2527,13 @@ path = "src/api.cppm" kind = "lib" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->lib.path.string(), "src/api.cppm"); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->lib.path.string(), "src/api.cppm")); auto root = mcpp::manifest::resolve_lib_root_path(*m); - EXPECT_EQ(root.string(), "src/api.cppm"); -} + expect(eq(root.string(), "src/api.cppm")); +}}; -TEST(Manifest, HasLibTargetFalseForBareBinaryManifest) { +UTest has_lib_target_false_for_bare_binary_manifest{"Manifest/HasLibTargetFalseForBareBinaryManifest", [] { // No [targets.*] declared → parse_string leaves targets empty. // load() would later infer a bin/lib from sources, but parse_string // alone leaves it bare; either way no lib target. @@ -2570,11 +2543,11 @@ name = "mcpp" version = "0.0.2" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_FALSE(mcpp::manifest::has_lib_target(*m)); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(!mcpp::manifest::has_lib_target(*m)); +}}; -TEST(Manifest, ParsesPerTargetFlagsAndRequiredFeatures) { +UTest parses_per_target_flags_and_required_features{"Manifest/ParsesPerTargetFlagsAndRequiredFeatures", [] { constexpr auto src = R"( [package] name = "app" @@ -2588,22 +2561,22 @@ cflags = ["-DPURE_C"] required_features = ["server"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->targets.size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->targets.size(), 1u))); auto& t = m->targets[0]; - ASSERT_EQ(t.defines.size(), 2u); - EXPECT_EQ(t.defines[0], "BUILD_SERVER=1"); - EXPECT_EQ(t.defines[1], "PORT=8080"); - ASSERT_EQ(t.cxxflags.size(), 1u); - EXPECT_EQ(t.cxxflags[0], "-fno-exceptions"); - ASSERT_EQ(t.cflags.size(), 1u); - EXPECT_EQ(t.cflags[0], "-DPURE_C"); - ASSERT_EQ(t.requiredFeatures.size(), 1u); - EXPECT_EQ(t.requiredFeatures[0], "server"); - EXPECT_TRUE(m->schemaWarnings.empty()); -} + expect(fatal(eq(t.defines.size(), 2u))); + expect(eq(t.defines[0], "BUILD_SERVER=1")); + expect(eq(t.defines[1], "PORT=8080")); + expect(fatal(eq(t.cxxflags.size(), 1u))); + expect(eq(t.cxxflags[0], "-fno-exceptions")); + expect(fatal(eq(t.cflags.size(), 1u))); + expect(eq(t.cflags[0], "-DPURE_C")); + expect(fatal(eq(t.requiredFeatures.size(), 1u))); + expect(eq(t.requiredFeatures[0], "server")); + expect(m->schemaWarnings.empty()); +}}; -TEST(Manifest, WarnsOnUnsupportedTargetKey) { +UTest warns_on_unsupported_target_key{"Manifest/WarnsOnUnsupportedTargetKey", [] { constexpr auto src = R"( [package] name = "app" @@ -2614,14 +2587,14 @@ main = "src/main.cpp" cxxfalgs = ["-DTYPO"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->schemaWarnings.size(), 1u); - EXPECT_NE(m->schemaWarnings[0].find("cxxfalgs"), std::string::npos); - EXPECT_NE(m->schemaWarnings[0].find("unsupported key"), std::string::npos); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->schemaWarnings.size(), 1u))); + expect(ne(m->schemaWarnings[0].find("cxxfalgs"), std::string::npos)); + expect(ne(m->schemaWarnings[0].find("unsupported key"), std::string::npos)); +}}; // #618: `windows_subsystem` / `windows_entry` on an executable target. -TEST(Manifest, ParsesWindowsSubsystemAndEntryOnABinaryTarget) { +UTest parses_windows_subsystem_and_entry_on_a_binary_target{"Manifest/ParsesWindowsSubsystemAndEntryOnABinaryTarget", [] { constexpr auto src = R"( [package] name = "app" @@ -2633,16 +2606,16 @@ windows_subsystem = "windows" windows_entry = "wWinMain" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets[0].windowsSubsystem, "windows"); - EXPECT_EQ(m->targets[0].windowsEntry, "wWinMain"); - EXPECT_TRUE(m->schemaWarnings.empty()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->targets.size(), 1u))); + expect(eq(m->targets[0].windowsSubsystem, "windows")); + expect(eq(m->targets[0].windowsEntry, "wWinMain")); + expect(m->schemaWarnings.empty()); +}}; // #693: `windows_code_page` states the process code page of an executable, in // the vocabulary of the manifest's `activeCodePage` element. -TEST(Manifest, ParsesWindowsCodePageOnABinaryTarget) { +UTest parses_windows_code_page_on_a_binary_target{"Manifest/ParsesWindowsCodePageOnABinaryTarget", [] { for (std::string_view value : {"utf-8", "legacy"}) { const auto src = std::format(R"( [package] @@ -2654,16 +2627,16 @@ main = "src/main.cpp" windows_code_page = "{}" )", value); auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets[0].windowsCodePage, value); - EXPECT_TRUE(m->schemaWarnings.empty()); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->targets.size(), 1u))); + expect(eq(m->targets[0].windowsCodePage, value)); + expect(m->schemaWarnings.empty()); } -} +}}; // `windows_long_paths` (design 2026-10-16 §11): an executable's statement // that it handles paths past MAX_PATH; unset when the author says nothing. -TEST(Manifest, ParsesWindowsLongPathsOnABinaryTargetAndForTests) { +UTest parses_windows_long_paths_on_a_binary_target_and_for_tests{"Manifest/ParsesWindowsLongPathsOnABinaryTargetAndForTests", [] { for (bool value : {true, false}) { const auto src = std::format(R"( [package] @@ -2677,32 +2650,31 @@ windows_long_paths = {0} windows_long_paths = {0} )", value); auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->targets.size(), 1u); - ASSERT_TRUE(m->targets[0].windowsLongPaths.has_value()); - EXPECT_EQ(*m->targets[0].windowsLongPaths, value); - ASSERT_TRUE(m->testWindowsLongPaths.has_value()); - EXPECT_EQ(*m->testWindowsLongPaths, value); - EXPECT_TRUE(m->schemaWarnings.empty()); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->targets.size(), 1u))); + expect(fatal(m->targets[0].windowsLongPaths.has_value())); + expect(eq(*m->targets[0].windowsLongPaths, value)); + expect(fatal(m->testWindowsLongPaths.has_value())); + expect(eq(*m->testWindowsLongPaths, value)); + expect(m->schemaWarnings.empty()); } auto unset = mcpp::manifest::parse_string( "[package]\nname = \"app\"\nversion = \"0.1.0\"\n[targets.app]\nkind = \"bin\"\nmain = \"m.cpp\"\n"); - ASSERT_TRUE(unset.has_value()); - EXPECT_FALSE(unset->targets[0].windowsLongPaths.has_value()); + expect(fatal(unset.has_value())); + expect(!(unset->targets[0].windowsLongPaths.has_value())); auto wrong = mcpp::manifest::parse_string( "[package]\nname = \"app\"\nversion = \"0.1.0\"\n[targets.app]\nkind = \"bin\"\nmain = \"m.cpp\"\n" "windows_long_paths = \"yes\"\n"); - ASSERT_FALSE(wrong.has_value()); - EXPECT_NE(wrong.error().message.find("windows_long_paths must be true or false"), std::string::npos); + expect(fatal(!wrong.has_value())); + expect(ne(wrong.error().message.find("windows_long_paths must be true or false"), std::string::npos)); auto library = mcpp::manifest::parse_string( "[package]\nname = \"app\"\nversion = \"0.1.0\"\n[targets.core]\nkind = \"lib\"\n" "windows_long_paths = true\n"); - ASSERT_FALSE(library.has_value()); - EXPECT_NE(library.error().message.find("targets.core.windows_long_paths"), std::string::npos) - << library.error().message; -} + expect(fatal(!library.has_value())); + if (auto&& failed = expect(ne(library.error().message.find("targets.core.windows_long_paths"), std::string::npos)); !failed) failed << library.error().message; +}}; -TEST(Manifest, RefusesAnUnknownWindowsCodePageNamingTheAcceptedOnes) { +UTest refuses_an_unknown_windows_code_page_naming_the_accepted_ones{"Manifest/RefusesAnUnknownWindowsCodePageNamingTheAcceptedOnes", [] { constexpr auto src = R"( [package] name = "app" @@ -2713,12 +2685,11 @@ main = "src/main.cpp" windows_code_page = "65001" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("\"65001\" is not one of \"utf-8\", \"legacy\""), - std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("\"65001\" is not one of \"utf-8\", \"legacy\""), std::string::npos)); !failed) failed << m.error().message; +}}; -TEST(Manifest, RefusesWindowsKeysOnALibraryNamingTheTargetAndTheKey) { +UTest refuses_windows_keys_on_a_library_naming_the_target_and_the_key{"Manifest/RefusesWindowsKeysOnALibraryNamingTheTargetAndTheKey", [] { const std::pair keys[] = { {"windows_subsystem", "windows"}, {"windows_entry", "wmain"}, {"windows_code_page", "utf-8"}}; @@ -2733,19 +2704,17 @@ kind = "{}" {} = "{}" )", kind, key, value); auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()) << kind << " " << key; - EXPECT_NE(m.error().message.find(std::format("targets.core.{}", key)), - std::string::npos) << m.error().message; - EXPECT_NE(m.error().message.find("executable"), std::string::npos) - << m.error().message; + expect(fatal(!m.has_value())) << kind << " " << key; + if (auto&& failed = expect(ne(m.error().message.find(std::format("targets.core.{}", key)), std::string::npos)); !failed) failed << m.error().message; + if (auto&& failed = expect(ne(m.error().message.find("executable"), std::string::npos)); !failed) failed << m.error().message; } } -} +}}; // #622 A3: `kind = "app"` -- "the thing a user launches", on every row. Its // link form is a property of the row (`toolchain::triple::application_form`), // not of the manifest; see test_artifact_naming.cpp for that half. -TEST(Manifest, KindAppParsesToApplication) { +UTest kind_app_parses_to_application{"Manifest/KindAppParsesToApplication", [] { constexpr auto src = R"( [package] name = "myapp" @@ -2755,16 +2724,16 @@ kind = "app" main = "src/main.cpp" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets[0].kind, mcpp::manifest::Target::Application); - EXPECT_EQ(m->targets[0].main, "src/main.cpp"); - EXPECT_TRUE(m->targets[0].is_program()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->targets.size(), 1u))); + expect(eq(m->targets[0].kind, mcpp::manifest::Target::Application)); + expect(eq(m->targets[0].main, "src/main.cpp")); + expect(m->targets[0].is_program()); +}}; // `is_program()` is exactly {Binary, Application} -- the negative direction, // asserted on every OTHER kind so the predicate cannot silently widen. -TEST(Manifest, IsProgramIsExactlyBinaryAndApplication) { +UTest is_program_is_exactly_binary_and_application{"Manifest/IsProgramIsExactlyBinaryAndApplication", [] { auto kind_of = [](std::string_view kind) { auto src = std::format(R"( [package] @@ -2780,15 +2749,15 @@ kind = "{}" for (auto [kind, expected] : {std::pair{"bin", true}, {"app", true}, {"lib", false}, {"shared", false}}) { auto m = kind_of(kind); - ASSERT_TRUE(m.has_value()) << kind << ": " << m.error().format(); - ASSERT_EQ(m->targets.size(), 1u) << kind; - EXPECT_EQ(m->targets[0].is_program(), expected) << kind; + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << kind << ": " << m.error().format(); + expect(fatal(eq(m->targets.size(), 1u))) << kind; + expect(eq(m->targets[0].is_program(), expected)) << kind; } -} +}}; // The refusal for an unrecognized `kind` lists all five accepted spellings -- // including the newest -- so a typo's message never falls behind the parser. -TEST(Manifest, KindRefusalListsAllFiveKinds) { +UTest kind_refusal_lists_all_five_kinds{"Manifest/KindRefusalListsAllFiveKinds", [] { constexpr auto src = R"( [package] name = "p" @@ -2797,15 +2766,14 @@ version = "0.1.0" kind = "framework" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("'bin', 'app', 'lib', 'shared' or 'entry'"), - std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("'bin', 'app', 'lib', 'shared' or 'entry'"), std::string::npos)); !failed) failed << m.error().message; +}}; // `main` is required for `app` exactly as it is for `bin` -- on Android it // becomes a translation unit of the shared library rather than an // executable's entry, but it is still named the same way. -TEST(Manifest, ApplicationRequiresMainField) { +UTest application_requires_main_field{"Manifest/ApplicationRequiresMainField", [] { constexpr auto src = R"( [package] name = "myapp" @@ -2814,16 +2782,15 @@ version = "0.1.0" kind = "app" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("requires 'main'"), std::string::npos) - << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("requires 'main'"), std::string::npos)); !failed) failed << m.error().message; +}}; // `windows_subsystem` / `windows_entry` accept `app` exactly as they accept // `bin` (#622 A3) -- the negative direction is // RefusesWindowsKeysOnALibraryNamingTheTargetAndTheKey above, which still // covers `lib` and `shared`. -TEST(Manifest, WindowsKeysAreAcceptedOnAnApplicationTarget) { +UTest windows_keys_are_accepted_on_an_application_target{"Manifest/WindowsKeysAreAcceptedOnAnApplicationTarget", [] { constexpr auto src = R"( [package] name = "app" @@ -2835,13 +2802,13 @@ windows_subsystem = "windows" windows_entry = "wWinMain" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets[0].windowsSubsystem, "windows"); - EXPECT_EQ(m->targets[0].windowsEntry, "wWinMain"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->targets.size(), 1u))); + expect(eq(m->targets[0].windowsSubsystem, "windows")); + expect(eq(m->targets[0].windowsEntry, "wWinMain")); +}}; -TEST(Manifest, RefusesAnUnknownWindowsValueNamingTheAcceptedOnes) { +UTest refuses_an_unknown_windows_value_naming_the_accepted_ones{"Manifest/RefusesAnUnknownWindowsValueNamingTheAcceptedOnes", [] { constexpr auto subsystem = R"( [package] name = "app" @@ -2852,9 +2819,8 @@ main = "src/main.cpp" windows_subsystem = "gui" )"; auto m = mcpp::manifest::parse_string(subsystem); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("\"gui\" is not one of \"console\", \"windows\""), - std::string::npos) << m.error().message; + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("\"gui\" is not one of \"console\", \"windows\""), std::string::npos)); !failed) failed << m.error().message; constexpr auto entry = R"( [package] @@ -2866,15 +2832,14 @@ main = "src/main.cpp" windows_entry = "mainCRTStartup" )"; auto e = mcpp::manifest::parse_string(entry); - ASSERT_FALSE(e.has_value()); - EXPECT_NE(e.error().message.find( - "is not one of \"main\", \"wmain\", \"WinMain\", \"wWinMain\""), - std::string::npos) << e.error().message; -} + expect(fatal(!e.has_value())); + if (auto&& failed = expect(ne(e.error().message.find( + "is not one of \"main\", \"wmain\", \"WinMain\", \"wWinMain\""), std::string::npos)); !failed) failed << e.error().message; +}}; // The key list in the warning is generated from the list the parser accepts, // so a key the parser reads cannot be missing from the message. -TEST(Manifest, UnsupportedTargetKeyWarningListsEveryAcceptedKey) { +UTest unsupported_target_key_warning_lists_every_accepted_key{"Manifest/UnsupportedTargetKeyWarningListsEveryAcceptedKey", [] { constexpr auto src = R"( [package] name = "app" @@ -2885,14 +2850,14 @@ main = "src/main.cpp" bogus = "x" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->schemaWarnings.size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->schemaWarnings.size(), 1u))); for (auto key : {"exports", "windows_subsystem", "windows_entry", "required_features"}) - EXPECT_NE(m->schemaWarnings[0].find(key), std::string::npos) + expect(ne(m->schemaWarnings[0].find(key), std::string::npos)) << key << " missing from: " << m->schemaWarnings[0]; -} +}}; -TEST(Manifest, RejectsStdFlagInTargetCxxflags) { +UTest rejects_std_flag_in_target_cxxflags{"Manifest/RejectsStdFlagInTargetCxxflags", [] { constexpr auto src = R"( [package] name = "app" @@ -2903,11 +2868,11 @@ main = "src/main.cpp" cxxflags = ["-std=c++20"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("[package].standard"), std::string::npos); -} + expect(fatal(!m.has_value())); + expect(ne(m.error().message.find("[package].standard"), std::string::npos)); +}}; -TEST(ListXpkgVersions, IgnoresCommentedEntries) { +UTest ignores_commented_entries{"ListXpkgVersions/IgnoresCommentedEntries", [] { constexpr auto src = R"( package = { xpm = { @@ -2919,9 +2884,9 @@ package = { } )"; auto v = mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); - ASSERT_EQ(v.size(), 1u); - EXPECT_EQ(v[0], "0.1.0"); -} + expect(fatal(eq(v.size(), 1u))); + expect(eq(v[0], "0.1.0")); +}}; // ─── xpkg_lua_identity_matches — descriptor identity gate ─────────── // @@ -2949,72 +2914,72 @@ package = { )"; } // namespace -TEST(XpkgIdentity, CompatDescriptorMatchesCompatRequest) { - EXPECT_TRUE(mcpp::manifest::xpkg_lua_identity_matches( +UTest compat_descriptor_matches_compat_request{"XpkgIdentity/CompatDescriptorMatchesCompatRequest", [] { + expect(mcpp::manifest::xpkg_lua_identity_matches( kCompatZlibLua, "compat", "zlib")); -} +}}; -TEST(XpkgIdentity, UpstreamBareZlibDoesNotMatchCompatRequest) { +UTest upstream_bare_zlib_does_not_match_compat_request{"XpkgIdentity/UpstreamBareZlibDoesNotMatchCompatRequest", [] { // The crux of the incident: a foreign bare `zlib.lua` must be rejected for // a `compat.zlib` request, no matter that the candidate filename matched. - EXPECT_FALSE(mcpp::manifest::xpkg_lua_identity_matches( + expect(!mcpp::manifest::xpkg_lua_identity_matches( kUpstreamZlibLua, "compat", "zlib")); -} +}}; -TEST(XpkgIdentity, DescriptorDeclaringNamespaceMatchesOnlyThatNamespace) { +UTest descriptor_declaring_namespace_matches_only_that_namespace{"XpkgIdentity/DescriptorDeclaringNamespaceMatchesOnlyThatNamespace", [] { // When a descriptor DECLARES its namespace, it matches that namespace and no // other. (Index-owned namespace for *no-namespace* descriptors — attributing // a bare `zlib.lua` to its owning index as `(xim, zlib)` — is deferred to the // §4.1 follow-up, since the content-only gate cannot know a file's index.) constexpr std::string_view ximZlib = R"(package = { namespace = "xim", name = "zlib" })"; - EXPECT_TRUE(mcpp::manifest::xpkg_lua_identity_matches( + expect(mcpp::manifest::xpkg_lua_identity_matches( ximZlib, "xim", "zlib", /*allowLegacyBareDefault=*/false)); - EXPECT_FALSE(mcpp::manifest::xpkg_lua_identity_matches( + expect(!mcpp::manifest::xpkg_lua_identity_matches( ximZlib, "compat", "zlib")); -} +}}; -TEST(XpkgIdentity, NoDeclaredNameIsAcceptedLeniently) { +UTest no_declared_name_is_accepted_leniently{"XpkgIdentity/NoDeclaredNameIsAcceptedLeniently", [] { // A descriptor without a package.name cannot be verified → accepted. constexpr std::string_view noName = R"(package = { version = "1.0.0" })"; - EXPECT_TRUE(mcpp::manifest::xpkg_lua_identity_matches(noName, "compat", "zlib")); -} + expect(mcpp::manifest::xpkg_lua_identity_matches(noName, "compat", "zlib")); +}}; -TEST(XpkgIdentity, DefaultNamespaceBareNameGatedByFlag) { +UTest default_namespace_bare_name_gated_by_flag{"XpkgIdentity/DefaultNamespaceBareNameGatedByFlag", [] { // Default-namespace (mcpplibs) bare-named legacy descriptor: accepted only // when the legacy-bare flag is on. constexpr std::string_view bareDefault = R"(package = { name = "cmdline" })"; - EXPECT_TRUE(mcpp::manifest::xpkg_lua_identity_matches( + expect(mcpp::manifest::xpkg_lua_identity_matches( bareDefault, "mcpplibs", "cmdline", /*allowLegacyBareDefault=*/true)); - EXPECT_FALSE(mcpp::manifest::xpkg_lua_identity_matches( + expect(!mcpp::manifest::xpkg_lua_identity_matches( bareDefault, "mcpplibs", "cmdline", /*allowLegacyBareDefault=*/false)); -} +}}; -TEST(XpkgIdentity, EmptyNamespaceDiscoveryMatchesNamespacedDescriptor) { +UTest empty_namespace_discovery_matches_namespaced_descriptor{"XpkgIdentity/EmptyNamespaceDiscoveryMatchesNamespacedDescriptor", [] { // `mcpp new --template llmapi` reads with an empty (unknown) namespace and // derives the real namespace from the file. A namespaced descriptor must // still be discoverable by its short name. constexpr std::string_view llmapi = R"( package = { namespace = "mcpplibs", name = "mcpplibs.llmapi", version = "0.2.8" } )"; - EXPECT_TRUE(mcpp::manifest::xpkg_lua_identity_matches(llmapi, "", "llmapi")); + expect(mcpp::manifest::xpkg_lua_identity_matches(llmapi, "", "llmapi")); // ...but it is NOT a match for a different short name. - EXPECT_FALSE(mcpp::manifest::xpkg_lua_identity_matches(llmapi, "", "zlib")); -} + expect(!mcpp::manifest::xpkg_lua_identity_matches(llmapi, "", "zlib")); +}}; -TEST(XpkgIdentity, DefaultNamespaceRequestDoesNotMatchCompatPackage) { +UTest default_namespace_request_does_not_match_compat_package{"XpkgIdentity/DefaultNamespaceRequestDoesNotMatchCompatPackage", [] { // Namespace omission now means exactly mcpplibs. A compat package must be // selected as `compat.gtest`, never smuggled through an identity alias. constexpr std::string_view compatGtest = R"(package = { namespace = "compat", name = "compat.gtest", version = "1.15.2" })"; - EXPECT_FALSE(mcpp::manifest::xpkg_lua_identity_matches( + expect(!mcpp::manifest::xpkg_lua_identity_matches( compatGtest, "mcpplibs", "gtest")); - EXPECT_FALSE(mcpp::manifest::xpkg_lua_identity_matches( + expect(!mcpp::manifest::xpkg_lua_identity_matches( compatGtest, "mcpplibs", "gtest", /*allowLegacyBareDefault=*/false)); // But a different default-ns name does not match it. - EXPECT_FALSE(mcpp::manifest::xpkg_lua_identity_matches( + expect(!mcpp::manifest::xpkg_lua_identity_matches( compatGtest, "mcpplibs", "zlib")); -} +}}; // ─── xpkg_wire_address — the address xlings is asked to install ───── // @@ -3026,87 +2991,87 @@ TEST(XpkgIdentity, DefaultNamespaceRequestDoesNotMatchCompatPackage) { // read `compat.gtest`, so a hardcoded `compat.` retry caught it. The // index's SPEC-001 short-name migration removed that coincidence. -TEST(XpkgWireAddress, CompatDescriptorAddressesCompat) { +UTest compat_descriptor_addresses_compat{"XpkgWireAddress/CompatDescriptorAddressesCompat", [] { constexpr std::string_view compatGtest = R"(package = { namespace = "compat", name = "gtest", version = "1.15.2" })"; auto addr = mcpp::manifest::xpkg_wire_address(compatGtest, "compat", "gtest"); - EXPECT_EQ(addr.ns, "compat"); - EXPECT_EQ(addr.name, "gtest"); - EXPECT_EQ(addr.target, "compat:gtest"); // NOT "mcpplibs:gtest" -} + expect(eq(addr.ns, "compat")); + expect(eq(addr.name, "gtest")); + expect(eq(addr.target, "compat:gtest")); // NOT "mcpplibs:gtest" +}}; -TEST(XpkgWireAddress, DefaultNamespaceDescriptorIsUnchanged) { +UTest default_namespace_descriptor_is_unchanged{"XpkgWireAddress/DefaultNamespaceDescriptorIsUnchanged", [] { // The 8 mcpplibs descriptors in the live index must address exactly as // before — this fix moves only the packages that were already broken. constexpr std::string_view cmdline = R"(package = { namespace = "mcpplibs", name = "cmdline", version = "0.0.1" })"; auto addr = mcpp::manifest::xpkg_wire_address(cmdline, "mcpplibs", "cmdline"); - EXPECT_EQ(addr.target, "mcpplibs:cmdline"); -} + expect(eq(addr.target, "mcpplibs:cmdline")); +}}; -TEST(XpkgWireAddress, LegacyFqnDescriptorKeepsItsLiteralName) { +UTest legacy_fqn_descriptor_keeps_its_literal_name{"XpkgWireAddress/LegacyFqnDescriptorKeepsItsLiteralName", [] { // Pre-SPEC-001 spelling. The literal `name` IS the index's key, so it goes // out verbatim — only the namespace half is corrected. constexpr std::string_view legacy = R"(package = { namespace = "compat", name = "compat.zlib", version = "1.3.2" })"; auto addr = mcpp::manifest::xpkg_wire_address(legacy, "mcpplibs", "zlib"); - EXPECT_EQ(addr.ns, "compat"); - EXPECT_EQ(addr.name, "compat.zlib"); - EXPECT_EQ(addr.target, "compat:compat.zlib"); -} + expect(eq(addr.ns, "compat")); + expect(eq(addr.name, "compat.zlib")); + expect(eq(addr.target, "compat:compat.zlib")); +}}; -TEST(XpkgWireAddress, QualifiedRequestIsUnchanged) { +UTest qualified_request_is_unchanged{"XpkgWireAddress/QualifiedRequestIsUnchanged", [] { // `[dependencies.compat] gtest = ...` already worked (this is the spelling // every mcpp-index example uses, which is why the index CI stayed green). constexpr std::string_view compatGtest = R"(package = { namespace = "compat", name = "gtest", version = "1.15.2" })"; auto addr = mcpp::manifest::xpkg_wire_address(compatGtest, "compat", "gtest"); - EXPECT_EQ(addr.target, "compat:gtest"); -} + expect(eq(addr.target, "compat:gtest")); +}}; -TEST(XpkgWireAddress, NestedNamespaceIsUnchanged) { +UTest nested_namespace_is_unchanged{"XpkgWireAddress/NestedNamespaceIsUnchanged", [] { constexpr std::string_view capiLua = R"(package = { namespace = "mcpplibs.capi", name = "lua", version = "5.4.7" })"; auto addr = mcpp::manifest::xpkg_wire_address(capiLua, "mcpplibs.capi", "lua"); - EXPECT_EQ(addr.target, "mcpplibs.capi:lua"); -} + expect(eq(addr.target, "mcpplibs.capi:lua")); +}}; -TEST(XpkgWireAddress, DescriptorWithoutNamespaceInheritsTheRequest) { +UTest descriptor_without_namespace_inherits_the_request{"XpkgWireAddress/DescriptorWithoutNamespaceInheritsTheRequest", [] { // A no-namespace descriptor inherits the namespace owned by the index it // was found in — at this seam, the request coordinate. Same rule the // identity gate used to accept it. constexpr std::string_view bare = R"(package = { name = "cmdline" })"; auto addr = mcpp::manifest::xpkg_wire_address(bare, "mcpplibs", "cmdline"); - EXPECT_EQ(addr.ns, "mcpplibs"); - EXPECT_EQ(addr.target, "mcpplibs:cmdline"); -} + expect(eq(addr.ns, "mcpplibs")); + expect(eq(addr.target, "mcpplibs:cmdline")); +}}; -TEST(XpkgWireAddress, NamespacelessRequestAddressesTheBareLiteral) { +UTest namespaceless_request_addresses_the_bare_literal{"XpkgWireAddress/NamespacelessRequestAddressesTheBareLiteral", [] { // xim upstream packages (`opencv`) have no namespace on either side. constexpr std::string_view opencv = R"(package = { name = "opencv" })"; auto addr = mcpp::manifest::xpkg_wire_address(opencv, "", "opencv"); - EXPECT_EQ(addr.ns, ""); - EXPECT_EQ(addr.target, "opencv"); -} + expect(eq(addr.ns, "")); + expect(eq(addr.target, "opencv")); +}}; -TEST(XpkgWireAddress, NoDescriptorKeepsTheHistoricalDerivation) { +UTest no_descriptor_keeps_the_historical_derivation{"XpkgWireAddress/NoDescriptorKeepsTheHistoricalDerivation", [] { // Index not synced yet: mcpp has nothing to read, so it must NOT invent a // new address — the failure has to stay exactly where it was. auto addr = mcpp::manifest::xpkg_wire_address("", "mcpplibs", "gtest"); - EXPECT_EQ(addr.name, "mcpplibs.gtest"); - EXPECT_EQ(addr.target, "mcpplibs:mcpplibs.gtest"); + expect(eq(addr.name, "mcpplibs.gtest")); + expect(eq(addr.target, "mcpplibs:mcpplibs.gtest")); auto bare = mcpp::manifest::xpkg_wire_address("", "", "opencv"); - EXPECT_EQ(bare.target, "opencv"); -} + expect(eq(bare.target, "opencv")); +}}; -TEST(XpkgWireAddress, DescriptorWithoutNameFallsBackToDerivation) { +UTest descriptor_without_name_falls_back_to_derivation{"XpkgWireAddress/DescriptorWithoutNameFallsBackToDerivation", [] { // A descriptor that declares no `name` cannot supply a wire key (the // identity gate accepts it leniently, so it can reach here). constexpr std::string_view noName = R"(package = { version = "1.0.0" })"; auto addr = mcpp::manifest::xpkg_wire_address(noName, "mcpplibs", "gtest"); - EXPECT_EQ(addr.target, "mcpplibs:mcpplibs.gtest"); -} + expect(eq(addr.target, "mcpplibs:mcpplibs.gtest")); +}}; // ─── INV-NAME: xpkg_name_form_violation (#278) ────────────────────── // @@ -3114,68 +3079,65 @@ TEST(XpkgWireAddress, DescriptorWithoutNameFallsBackToDerivation) { // `package.namespace` (SPEC-001 §3.2). The legacy fully-qualified spelling // (`namespace="compat", name="compat.zlib"`) stays accepted for compatibility. -TEST(XpkgNameForm, ShortNameIsCanonical) { +UTest short_name_is_canonical{"XpkgNameForm/ShortNameIsCanonical", [] { // SPEC-001 form: namespace carries the path, name is one segment. - EXPECT_FALSE(mcpp::manifest::xpkg_name_form_violation("chriskohlhoff", "asio") - .has_value()); - EXPECT_FALSE(mcpp::manifest::xpkg_name_form_violation("mcpplibs.capi", "lua") - .has_value()); -} - -TEST(XpkgNameForm, FqnFormIsClean) { - EXPECT_FALSE(mcpp::manifest::xpkg_name_form_violation( - "chriskohlhoff", "chriskohlhoff.asio").has_value()); -} - -TEST(XpkgNameForm, NoNamespaceIsClean) { + expect(!(mcpp::manifest::xpkg_name_form_violation("chriskohlhoff", "asio") + .has_value())); + expect(!(mcpp::manifest::xpkg_name_form_violation("mcpplibs.capi", "lua") + .has_value())); +}}; + +UTest fqn_form_is_clean{"XpkgNameForm/FqnFormIsClean", [] { + expect(!(mcpp::manifest::xpkg_name_form_violation( + "chriskohlhoff", "chriskohlhoff.asio").has_value())); +}}; + +UTest no_namespace_is_clean{"XpkgNameForm/NoNamespaceIsClean", [] { // xim upstream packages declare no namespace and are keyed by the bare // name — legal, and outside INV-NAME's scope. - EXPECT_FALSE(mcpp::manifest::xpkg_name_form_violation("", "opencv") - .has_value()); -} + expect(!(mcpp::manifest::xpkg_name_form_violation("", "opencv") + .has_value())); +}}; -TEST(XpkgNameForm, NoNameIsClean) { +UTest no_name_is_clean{"XpkgNameForm/NoNameIsClean", [] { // Cannot verify → lenient, matching the identity gate's stance. - EXPECT_FALSE(mcpp::manifest::xpkg_name_form_violation("compat", "") - .has_value()); -} + expect(!(mcpp::manifest::xpkg_name_form_violation("compat", "") + .has_value())); +}}; -TEST(XpkgNameForm, CompatAliasIsClean) { +UTest compat_alias_is_clean{"XpkgNameForm/CompatAliasIsClean", [] { // Legacy descriptors repeat the declared namespace in package.name. That // source shape remains valid even though consumers now select compat // explicitly. - EXPECT_FALSE(mcpp::manifest::xpkg_name_form_violation("compat", "compat.gtest") - .has_value()); -} + expect(!(mcpp::manifest::xpkg_name_form_violation("compat", "compat.gtest") + .has_value())); +}}; -TEST(XpkgNameForm, NestedNamespaceIsClean) { - EXPECT_FALSE(mcpp::manifest::xpkg_name_form_violation( - "mcpplibs.capi", "mcpplibs.capi.lua").has_value()); -} +UTest nested_namespace_is_clean{"XpkgNameForm/NestedNamespaceIsClean", [] { + expect(!(mcpp::manifest::xpkg_name_form_violation( + "mcpplibs.capi", "mcpplibs.capi.lua").has_value())); +}}; -TEST(XpkgNameForm, PrefixWithoutShortSegmentIsAViolation) { +UTest prefix_without_short_segment_is_a_violation{"XpkgNameForm/PrefixWithoutShortSegmentIsAViolation", [] { // `name` equal to the namespace itself carries no short segment. - EXPECT_TRUE(mcpp::manifest::xpkg_name_form_violation("compat", "compat.") + expect(mcpp::manifest::xpkg_name_form_violation("compat", "compat.") .has_value()); -} +}}; -TEST(XpkgNameForm, FromLuaAcceptsBothCanonicalAndLegacy) { +UTest from_lua_accepts_both_canonical_and_legacy{"XpkgNameForm/FromLuaAcceptsBothCanonicalAndLegacy", [] { constexpr std::string_view shortLua = R"(package = { namespace = "aimol", name = "tensorvia-cpu" })"; constexpr std::string_view legacyFqnLua = R"(package = { namespace = "aimol", name = "aimol.tensorvia-cpu" })"; // Canonical short-name form and the legacy FQN spelling both resolve to // the same identity, so both are accepted. - EXPECT_FALSE( - mcpp::manifest::xpkg_name_form_violation_from_lua(shortLua).has_value()); - EXPECT_FALSE( - mcpp::manifest::xpkg_name_form_violation_from_lua(legacyFqnLua).has_value()); + expect(!(mcpp::manifest::xpkg_name_form_violation_from_lua(shortLua).has_value())); + expect(!(mcpp::manifest::xpkg_name_form_violation_from_lua(legacyFqnLua).has_value())); // But a short name that still carries a dot is rejected. constexpr std::string_view nonAtomic = R"(package = { namespace = "mcpplibs", name = "capi.lua" })"; - EXPECT_TRUE( - mcpp::manifest::xpkg_name_form_violation_from_lua(nonAtomic).has_value()); -} + expect(mcpp::manifest::xpkg_name_form_violation_from_lua(nonAtomic).has_value()); +}}; // ─── canonical_xpkg_identity — the unified (ns, name) model (§4.2) ─── // @@ -3192,79 +3154,75 @@ mcpp::manifest::XpkgIdentity want(std::string n, std::string s) { } } // namespace -TEST(CanonicalIdentity, PrefixEmbeddedNameCollapses) { +UTest prefix_embedded_name_collapses{"CanonicalIdentity/PrefixEmbeddedNameCollapses", [] { // ns=compat, name=compat.zlib → (compat, zlib) - EXPECT_EQ(id("compat", "compat.zlib"), want("compat", "zlib")); -} + expect(eq(id("compat", "compat.zlib"), want("compat", "zlib"))); +}}; -TEST(CanonicalIdentity, BareNameCombinesWithNamespace) { +UTest bare_name_combines_with_namespace{"CanonicalIdentity/BareNameCombinesWithNamespace", [] { // ns=mcpplibs, name=cmdline → (mcpplibs, cmdline) - EXPECT_EQ(id("mcpplibs", "cmdline"), want("mcpplibs", "cmdline")); -} + expect(eq(id("mcpplibs", "cmdline"), want("mcpplibs", "cmdline"))); +}}; -TEST(CanonicalIdentity, AlreadyQualifiedNameIsIdempotent) { - EXPECT_EQ(id("mcpplibs", "mcpplibs.cmdline"), want("mcpplibs", "cmdline")); -} +UTest already_qualified_name_is_idempotent{"CanonicalIdentity/AlreadyQualifiedNameIsIdempotent", [] { + expect(eq(id("mcpplibs", "mcpplibs.cmdline"), want("mcpplibs", "cmdline"))); +}}; -TEST(CanonicalIdentity, NoNamespaceInheritsOwningIndex) { +UTest no_namespace_inherits_owning_index{"CanonicalIdentity/NoNamespaceInheritsOwningIndex", [] { // ns=∅, name=zlib, index=xim → (xim, zlib) - EXPECT_EQ(id("", "zlib", "xim"), want("xim", "zlib")); - EXPECT_EQ(id("", "tinycfg", "local-dev"), want("local-dev", "tinycfg")); -} + expect(eq(id("", "zlib", "xim"), want("xim", "zlib"))); + expect(eq(id("", "tinycfg", "local-dev"), want("local-dev", "tinycfg"))); +}}; -TEST(CanonicalIdentity, DeclaredNamespaceWinsOverIndexDefault) { - EXPECT_EQ(id("compat", "compat.zlib", "xim"), want("compat", "zlib")); -} +UTest declared_namespace_wins_over_index_default{"CanonicalIdentity/DeclaredNamespaceWinsOverIndexDefault", [] { + expect(eq(id("compat", "compat.zlib", "xim"), want("compat", "zlib"))); +}}; -TEST(CanonicalIdentity, DottedNameWithNoNamespaceIsNotReinterpreted) { +UTest dotted_name_with_no_namespace_is_not_reinterpreted{"CanonicalIdentity/DottedNameWithNoNamespaceIsNotReinterpreted", [] { // SPEC-001: identity is exactly what the descriptor declares. A dotted // `name` with no namespace is NOT silently split into one — that inferred // a namespace nobody wrote. It stays literal here and is rejected by // xpkg_name_form_violation instead. - EXPECT_EQ(id("", "a.b"), want("", "a.b")); - EXPECT_EQ(id("", "x.y.z"), want("", "x.y.z")); - EXPECT_TRUE(mcpp::manifest::xpkg_name_form_violation("", "a.b").has_value()); -} + expect(eq(id("", "a.b"), want("", "a.b"))); + expect(eq(id("", "x.y.z"), want("", "x.y.z"))); + expect(mcpp::manifest::xpkg_name_form_violation("", "a.b").has_value()); +}}; -TEST(CanonicalIdentity, HierarchicalNamespaceIsSupported) { +UTest hierarchical_namespace_is_supported{"CanonicalIdentity/HierarchicalNamespaceIsSupported", [] { // ns=a.b, name=c → (a.b, c) ; name is the single trailing segment. - EXPECT_EQ(id("a.b", "c"), want("a.b", "c")); - EXPECT_EQ(id("mcpplibs.capi", "lua"), want("mcpplibs.capi", "lua")); + expect(eq(id("a.b", "c"), want("a.b", "c"))); + expect(eq(id("mcpplibs.capi", "lua"), want("mcpplibs.capi", "lua"))); // Legacy FQN spelling under the SAME namespace strips the prefix. - EXPECT_EQ(id("mcpplibs.capi", "mcpplibs.capi.lua"), - want("mcpplibs.capi", "lua")); + expect(eq(id("mcpplibs.capi", "mcpplibs.capi.lua"), want("mcpplibs.capi", "lua"))); // But a shorter declared namespace no longer "absorbs" the extra segment: // the descriptor said mcpplibs, so the identity is mcpplibs — the leftover // dot makes it a form violation rather than a silent (mcpplibs.capi, lua). - EXPECT_EQ(id("mcpplibs", "mcpplibs.capi.lua"), want("mcpplibs", "capi.lua")); - EXPECT_TRUE(mcpp::manifest::xpkg_name_form_violation( + expect(eq(id("mcpplibs", "mcpplibs.capi.lua"), want("mcpplibs", "capi.lua"))); + expect(mcpp::manifest::xpkg_name_form_violation( "mcpplibs", "mcpplibs.capi.lua").has_value()); -} +}}; -TEST(CanonicalIdentity, BareNameNoNamespaceNoIndexStaysRootless) { +UTest bare_name_no_namespace_no_index_stays_rootless{"CanonicalIdentity/BareNameNoNamespaceNoIndexStaysRootless", [] { // The incident villain: a foreign bare descriptor with nothing to anchor it. - EXPECT_EQ(id("", "zlib"), want("", "zlib")); -} + expect(eq(id("", "zlib"), want("", "zlib"))); +}}; -TEST(CanonicalIdentity, FromLuaReadsDeclaredFields) { +UTest from_lua_reads_declared_fields{"CanonicalIdentity/FromLuaReadsDeclaredFields", [] { constexpr std::string_view lua = R"(package = { namespace = "compat", name = "compat.zlib" })"; - EXPECT_EQ(mcpp::manifest::canonical_xpkg_identity_from_lua(lua), - want("compat", "zlib")); + expect(eq(mcpp::manifest::canonical_xpkg_identity_from_lua(lua), want("compat", "zlib"))); // No-namespace descriptor + owning index. constexpr std::string_view bare = R"(package = { name = "tinycfg" })"; - EXPECT_EQ(mcpp::manifest::canonical_xpkg_identity_from_lua(bare, "local-dev"), - want("local-dev", "tinycfg")); + expect(eq(mcpp::manifest::canonical_xpkg_identity_from_lua(bare, "local-dev"), want("local-dev", "tinycfg"))); // No declared name → empty identity. constexpr std::string_view noName = R"(package = { version = "1.0" })"; - EXPECT_EQ(mcpp::manifest::canonical_xpkg_identity_from_lua(noName), - want("", "")); -} + expect(eq(mcpp::manifest::canonical_xpkg_identity_from_lua(noName), want("", ""))); +}}; // ── Descriptor grammar v2: Lua long-bracket strings + block comments ── // Design: .agents/docs/2026-07-08-index-version-semantics-and-descriptor-grammar-design.md (D1) -TEST(XpkgLongBracket, GeneratedFilesMultiline) { +UTest generated_files_multiline{"XpkgLongBracket/GeneratedFilesMultiline", [] { // Content carries braces, quotes, an embedded `]]` (level-2 brackets), // and a leading newline that Lua semantics strip. constexpr auto lua = R"( @@ -3285,13 +3243,12 @@ int f() { return "x]]y"[0]; } } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "lbtest", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_TRUE(m->buildConfig.generatedFiles.contains("mcpp_generated/gen.cc")); - EXPECT_EQ(m->buildConfig.generatedFiles["mcpp_generated/gen.cc"], - "export module gen;\nint f() { return \"x]]y\"[0]; }\n"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(m->buildConfig.generatedFiles.contains("mcpp_generated/gen.cc"))); + expect(eq(m->buildConfig.generatedFiles["mcpp_generated/gen.cc"], "export module gen;\nint f() { return \"x]]y\"[0]; }\n")); +}}; -TEST(XpkgLongBracket, BlockCommentWithBracesAndFakeMcpp) { +UTest block_comment_with_braces_and_fake_mcpp{"XpkgLongBracket/BlockCommentWithBracesAndFakeMcpp", [] { // A block comment containing braces and the literal text `mcpp = {` // must neither corrupt the structural depth count nor false-match // the segment locator. @@ -3311,12 +3268,12 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "cmt", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->modules.sources.size(), 1u); - EXPECT_EQ(m->modules.sources[0], "src/*.cpp"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->modules.sources.size(), 1u))); + expect(eq(m->modules.sources[0], "src/*.cpp")); +}}; -TEST(XpkgLongBracket, NoLeadingNewlineKeptVerbatim) { +UTest no_leading_newline_kept_verbatim{"XpkgLongBracket/NoLeadingNewlineKeptVerbatim", [] { // Without a newline after the opening bracket, content starts // immediately; CRLF right after the bracket is stripped as one break. constexpr auto lua = @@ -3331,12 +3288,12 @@ TEST(XpkgLongBracket, NoLeadingNewlineKeptVerbatim) { " },\n" "}\n"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "nl", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->buildConfig.generatedFiles["g.cc"], "inline"); - EXPECT_EQ(m->buildConfig.generatedFiles["h.cc"], "crlf"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->buildConfig.generatedFiles["g.cc"], "inline")); + expect(eq(m->buildConfig.generatedFiles["h.cc"], "crlf")); +}}; -TEST(XpkgLongBracket, UnknownKeysRecordedSchemaAccepted) { +UTest unknown_keys_recorded_schema_accepted{"XpkgLongBracket/UnknownKeysRecordedSchemaAccepted", [] { constexpr auto lua = R"( package = { spec = "1", @@ -3352,16 +3309,16 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "uk", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->xpkgUnknownKeys.size(), 2u); - EXPECT_EQ(m->xpkgUnknownKeys[0], "bogus_key"); - EXPECT_EQ(m->xpkgUnknownKeys[1], "future_table"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->xpkgUnknownKeys.size(), 2u))); + expect(eq(m->xpkgUnknownKeys[0], "bogus_key")); + expect(eq(m->xpkgUnknownKeys[1], "future_table")); +}}; // ── scan_overrides: author-asserted scan results ── // Design: .agents/docs/2026-07-08-scanner-backend-abstraction-design.md §3-pre -TEST(ScanOverrides, XpkgSegmentParses) { +UTest xpkg_segment_parses{"ScanOverrides/XpkgSegmentParses", [] { constexpr auto lua = R"( package = { spec = "1", @@ -3379,17 +3336,17 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "fmtlib.fmt", "12.2.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->modules.scanOverrides.size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->modules.scanOverrides.size(), 1u))); auto& ov = m->modules.scanOverrides.at("*/src/fmt.cc"); - ASSERT_EQ(ov.provides.size(), 1u); - EXPECT_EQ(ov.provides[0], "fmt"); - ASSERT_EQ(ov.imports.size(), 1u); - EXPECT_EQ(ov.imports[0], "std"); - EXPECT_TRUE(m->xpkgUnknownKeys.empty()); -} - -TEST(ScanOverrides, XpkgRejectsEmptyDeclaration) { + expect(fatal(eq(ov.provides.size(), 1u))); + expect(eq(ov.provides[0], "fmt")); + expect(fatal(eq(ov.imports.size(), 1u))); + expect(eq(ov.imports[0], "std")); + expect(m->xpkgUnknownKeys.empty()); +}}; + +UTest xpkg_rejects_empty_declaration{"ScanOverrides/XpkgRejectsEmptyDeclaration", [] { constexpr auto lua = R"( package = { spec = "1", @@ -3403,12 +3360,11 @@ package = { } )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "so", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("neither provides nor imports"), - std::string::npos) << m.error().format(); -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("neither provides nor imports"), std::string::npos)); !failed) failed << m.error().format(); +}}; -TEST(ScanOverrides, TomlParses) { +UTest toml_parses{"ScanOverrides/TomlParses", [] { constexpr auto src = R"( [package] name = "app" @@ -3425,19 +3381,19 @@ kind = "bin" main = "src/main.cpp" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->modules.scanOverrides.size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->modules.scanOverrides.size(), 1u))); auto& ov = m->modules.scanOverrides.at("vendor/fmt.cc"); - EXPECT_EQ(ov.provides, std::vector{"fmt"}); - EXPECT_EQ(ov.imports, std::vector{"std"}); -} + expect(eq(ov.provides, std::vector{"fmt"})); + expect(eq(ov.imports, std::vector{"std"})); +}}; // ── declarative source-set parity: mcpp.toml ↔ xpkg descriptor ─────────────── // One data model, two grammars (manifest.cppm): what a descriptor can express // through `features..sources` / `generated_files`, mcpp.toml must express // identically. This test locks the symmetry as a contract. -TEST(Manifest, FeatureSourcesParseFromToml) { +UTest feature_sources_parse_from_toml{"Manifest/FeatureSourcesParseFromToml", [] { constexpr auto src = R"( [package] name = "sym" @@ -3447,14 +3403,13 @@ default = [] compiled = { sources = ["src/impl/**"], defines = ["SYM_COMPILED"] } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_TRUE(m->buildConfig.featureSources.contains("compiled")); - EXPECT_EQ(m->buildConfig.featureSources.at("compiled"), - (std::vector{"src/impl/**"})); - ASSERT_TRUE(m->buildConfig.featureDefines.contains("compiled")); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(m->buildConfig.featureSources.contains("compiled"))); + expect(eq(m->buildConfig.featureSources.at("compiled"), (std::vector{"src/impl/**"}))); + expect(fatal(m->buildConfig.featureDefines.contains("compiled"))); +}}; -TEST(Manifest, GeneratedFilesParseFromToml) { +UTest generated_files_parse_from_toml{"Manifest/GeneratedFilesParseFromToml", [] { constexpr auto src = R"( [package] name = "genf" @@ -3467,15 +3422,15 @@ export module wrap; """ )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->buildConfig.generatedFiles.size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->buildConfig.generatedFiles.size(), 1u))); auto it = m->buildConfig.generatedFiles.find( std::filesystem::path("src/gen/wrap.cppm")); - ASSERT_NE(it, m->buildConfig.generatedFiles.end()); - EXPECT_EQ(it->second, "module;\nexport module wrap;\n"); -} + expect(fatal(ne(it, m->buildConfig.generatedFiles.end()))); + expect(eq(it->second, "module;\nexport module wrap;\n")); +}}; -TEST(Manifest, GeneratedFilesRejectEscapingPaths) { +UTest generated_files_reject_escaping_paths{"Manifest/GeneratedFilesRejectEscapingPaths", [] { for (auto path : { "\"../evil.cpp\"", "\"/abs/evil.cpp\"", "\"a/../../evil.cpp\"" }) { auto src = std::format(R"( [package] @@ -3485,11 +3440,11 @@ version = "1.0.0" {} = "x" )", path); auto m = mcpp::manifest::parse_string(src); - EXPECT_FALSE(m.has_value()) << "path accepted: " << path; + expect(!m.has_value()) << "path accepted: " << path; } -} +}}; -TEST(Manifest, TomlAndXpkgFeatureSourcesAreSymmetric) { +UTest toml_and_xpkg_feature_sources_are_symmetric{"Manifest/TomlAndXpkgFeatureSourcesAreSymmetric", [] { constexpr auto toml = R"( [package] name = "sym" @@ -3512,16 +3467,16 @@ mcpp = { } )"; auto mt = mcpp::manifest::parse_string(toml); - ASSERT_TRUE(mt.has_value()) << mt.error().format(); + if (auto&& failed = expect(fatal(mt.has_value())); !failed) failed << mt.error().format(); auto mx = mcpp::manifest::synthesize_from_xpkg_lua(lua, "sym", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(mx.has_value()) << mx.error().format(); + if (auto&& failed = expect(fatal(mx.has_value())); !failed) failed << mx.error().format(); - EXPECT_EQ(mt->buildConfig.featureSources, mx->buildConfig.featureSources); - EXPECT_EQ(mt->buildConfig.featureDefines, mx->buildConfig.featureDefines); - EXPECT_EQ(mt->buildConfig.generatedFiles, mx->buildConfig.generatedFiles); -} + expect(eq(mt->buildConfig.featureSources, mx->buildConfig.featureSources)); + expect(eq(mt->buildConfig.featureDefines, mx->buildConfig.featureDefines)); + expect(eq(mt->buildConfig.generatedFiles, mx->buildConfig.generatedFiles)); +}}; -TEST(Manifest, CfgConditionalSourcesParseFromToml) { +UTest cfg_conditional_sources_parse_from_toml{"Manifest/CfgConditionalSourcesParseFromToml", [] { constexpr auto src = R"( [package] name = "condsrc" @@ -3532,16 +3487,16 @@ sources = ["src/x86/**/*.asm", "!src/x86/legacy/**"] cxxflags = ["-DHAVE_X86=1"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->conditionalConfigs.size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->conditionalConfigs.size(), 1u))); auto& cc = m->conditionalConfigs[0]; - EXPECT_EQ(cc.predicate, "cfg(arch = \"x86_64\")"); - EXPECT_EQ(cc.inputs.sources, (std::vector{ - "src/x86/**/*.asm", "!src/x86/legacy/**"})); - EXPECT_EQ(cc.inputs.cxxflags, (std::vector{"-DHAVE_X86=1"})); -} + expect(eq(cc.predicate, "cfg(arch = \"x86_64\")")); + expect(eq(cc.inputs.sources, (std::vector{ + "src/x86/**/*.asm", "!src/x86/legacy/**"}))); + expect(eq(cc.inputs.cxxflags, (std::vector{"-DHAVE_X86=1"}))); +}}; -TEST(Manifest, TargetCfgParsesFromXpkgSymmetrically) { +UTest target_cfg_parses_from_xpkg_symmetrically{"Manifest/TargetCfgParsesFromXpkgSymmetrically", [] { constexpr auto lua = R"( package = { name = "condsrc" } mcpp = { @@ -3558,17 +3513,15 @@ mcpp = { } )"; auto mx = mcpp::manifest::synthesize_from_xpkg_lua(lua, "condsrc", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(mx.has_value()) << mx.error().format(); - ASSERT_EQ(mx->conditionalConfigs.size(), 2u); - EXPECT_EQ(mx->conditionalConfigs[0].predicate, "cfg(arch = \"x86_64\")"); - EXPECT_EQ(mx->conditionalConfigs[0].inputs.sources, - (std::vector{"src/x86/**/*.asm"})); - EXPECT_EQ(mx->conditionalConfigs[1].predicate, "cfg(windows)"); - EXPECT_EQ(mx->conditionalConfigs[1].inputs.ldflags, - (std::vector{"-lws2_32"})); -} + if (auto&& failed = expect(fatal(mx.has_value())); !failed) failed << mx.error().format(); + expect(fatal(eq(mx->conditionalConfigs.size(), 2u))); + expect(eq(mx->conditionalConfigs[0].predicate, "cfg(arch = \"x86_64\")")); + expect(eq(mx->conditionalConfigs[0].inputs.sources, (std::vector{"src/x86/**/*.asm"}))); + expect(eq(mx->conditionalConfigs[1].predicate, "cfg(windows)")); + expect(eq(mx->conditionalConfigs[1].inputs.ldflags, (std::vector{"-lws2_32"}))); +}}; -TEST(Manifest, PerGlobFlagsParseOrderedFromToml) { +UTest per_glob_flags_parse_ordered_from_toml{"Manifest/PerGlobFlagsParseOrderedFromToml", [] { constexpr auto src = R"( [package] name = "globf" @@ -3582,18 +3535,18 @@ flags = [ ] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto& gfs = m->buildConfig.globFlags; - ASSERT_EQ(gfs.size(), 3u); + expect(fatal(eq(gfs.size(), 3u))); // Declaration order IS the data (override semantics ride on it). - EXPECT_EQ(gfs[0].glob, "third_party/**"); - EXPECT_EQ(gfs[0].cxxflags, (std::vector{"-w"})); - EXPECT_EQ(gfs[1].glob, "src/simd/**/*.avx2.cpp"); - EXPECT_EQ(gfs[1].defines, (std::vector{"HAVE_AVX2"})); - EXPECT_EQ(gfs[2].asmflags, (std::vector{"-DPIC"})); -} - -TEST(Manifest, PerGlobFlagsRejectUnknownKeysAndMissingGlob) { + expect(eq(gfs[0].glob, "third_party/**")); + expect(eq(gfs[0].cxxflags, (std::vector{"-w"}))); + expect(eq(gfs[1].glob, "src/simd/**/*.avx2.cpp")); + expect(eq(gfs[1].defines, (std::vector{"HAVE_AVX2"}))); + expect(eq(gfs[2].asmflags, (std::vector{"-DPIC"}))); +}}; + +UTest per_glob_flags_reject_unknown_keys_and_missing_glob{"Manifest/PerGlobFlagsRejectUnknownKeysAndMissingGlob", [] { auto bad1 = mcpp::manifest::parse_string(R"( [package] name = "globf" @@ -3601,7 +3554,7 @@ version = "1.0.0" [build] flags = [{ glob = "src/**", ldflags = ["-lfoo"] }] )"); - EXPECT_FALSE(bad1.has_value()); // ldflags is not per-TU — closed grammar + expect(!bad1.has_value()); // ldflags is not per-TU — closed grammar auto bad2 = mcpp::manifest::parse_string(R"( [package] @@ -3610,10 +3563,10 @@ version = "1.0.0" [build] flags = [{ cxxflags = ["-w"] }] )"); - EXPECT_FALSE(bad2.has_value()); // missing glob -} + expect(!bad2.has_value()); // missing glob +}}; -TEST(Manifest, PerGlobFlagsParseFromXpkgSymmetrically) { +UTest per_glob_flags_parse_from_xpkg_symmetrically{"Manifest/PerGlobFlagsParseFromXpkgSymmetrically", [] { constexpr auto lua = R"( package = { name = "globf" } mcpp = { @@ -3625,16 +3578,14 @@ mcpp = { } )"; auto mx = mcpp::manifest::synthesize_from_xpkg_lua(lua, "globf", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(mx.has_value()) << mx.error().format(); - ASSERT_EQ(mx->buildConfig.globFlags.size(), 2u); - EXPECT_EQ(mx->buildConfig.globFlags[0].glob, "third_party/**"); - EXPECT_EQ(mx->buildConfig.globFlags[1].cxxflags, - (std::vector{"-mavx2"})); - EXPECT_EQ(mx->buildConfig.globFlags[1].defines, - (std::vector{"HAVE_AVX2"})); -} - -TEST(Manifest, AllowsBuildFlagsArrayOfTablesSyntax) { + if (auto&& failed = expect(fatal(mx.has_value())); !failed) failed << mx.error().format(); + expect(fatal(eq(mx->buildConfig.globFlags.size(), 2u))); + expect(eq(mx->buildConfig.globFlags[0].glob, "third_party/**")); + expect(eq(mx->buildConfig.globFlags[1].cxxflags, (std::vector{"-mavx2"}))); + expect(eq(mx->buildConfig.globFlags[1].defines, (std::vector{"HAVE_AVX2"}))); +}}; + +UTest allows_build_flags_array_of_tables_syntax{"Manifest/AllowsBuildFlagsArrayOfTablesSyntax", [] { // `[[build.flags]]` header form must still parse at the manifest layer // (not just the lower libs/toml layer) — the closed-grammar guard must // allowlist exactly this path. @@ -3652,14 +3603,14 @@ glob = "src/x86/**/*.asm" asmflags = ["-DPIC"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto& gfs = m->buildConfig.globFlags; - ASSERT_EQ(gfs.size(), 2u); - EXPECT_EQ(gfs[0].glob, "third_party/**"); - EXPECT_EQ(gfs[0].cxxflags, (std::vector{"-w"})); - EXPECT_EQ(gfs[1].glob, "src/x86/**/*.asm"); - EXPECT_EQ(gfs[1].asmflags, (std::vector{"-DPIC"})); -} + expect(fatal(eq(gfs.size(), 2u))); + expect(eq(gfs[0].glob, "third_party/**")); + expect(eq(gfs[0].cxxflags, (std::vector{"-w"}))); + expect(eq(gfs[1].glob, "src/x86/**/*.asm")); + expect(eq(gfs[1].asmflags, (std::vector{"-DPIC"}))); +}}; // Closed-grammar guard (review fix on #227/#228): `[[dependencies]]` is a // doubled-bracket typo for `[dependencies]`. Before this guard it parsed @@ -3667,7 +3618,7 @@ asmflags = ["-DPIC"] // [dependencies] via get_table(), which returns nullptr for a non-table // Value, so the whole section (all dependencies) silently vanished with no // error and no warning. It must now be a loud parse error. -TEST(Manifest, RejectsArrayOfTablesTypoOnDependencies) { +UTest rejects_array_of_tables_typo_on_dependencies{"Manifest/RejectsArrayOfTablesTypoOnDependencies", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "x" @@ -3676,16 +3627,14 @@ version = "0.1.0" [[dependencies]] gtest = "1.15.2" )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("array-of-tables"), std::string::npos) - << m.error().message; - EXPECT_NE(m.error().message.find("dependencies"), std::string::npos) - << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("array-of-tables"), std::string::npos)); !failed) failed << m.error().message; + if (auto&& failed = expect(ne(m.error().message.find("dependencies"), std::string::npos)); !failed) failed << m.error().message; +}}; // Same guard, a different non-allowlisted path — confirms the check isn't // hardcoded to just "dependencies". -TEST(Manifest, RejectsArrayOfTablesTypoOnToolchain) { +UTest rejects_array_of_tables_typo_on_toolchain{"Manifest/RejectsArrayOfTablesTypoOnToolchain", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "x" @@ -3694,17 +3643,16 @@ version = "0.1.0" [[toolchain]] linux = "gcc@15.1.0" )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("array-of-tables"), std::string::npos) - << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("array-of-tables"), std::string::npos)); !failed) failed << m.error().message; +}}; // #258: `[target.'cfg()'.build]` accepts the same per-glob `flags` array // `[build]` does. Before this, the conditional reader knew exactly four keys // and dropped everything else silently, so a per-OS flag table was // inexpressible in mcpp.toml while the xpkg descriptor's mcpp. sections // supported it — manifest-built packages were second-class. -TEST(Manifest, ConditionalSectionCarriesPerGlobFlags) { +UTest conditional_section_carries_per_glob_flags{"Manifest/ConditionalSectionCarriesPerGlobFlags", [] { auto tmp = std::filesystem::temp_directory_path() / "mcpp_cond_flags"; std::filesystem::create_directories(tmp); auto path = tmp / "mcpp.toml"; @@ -3727,30 +3675,28 @@ flags = [ )"; } auto m = mcpp::manifest::load(path); - ASSERT_TRUE(m) << (m ? "" : m.error().message); + if (auto&& failed = expect(fatal(bool(m))); !failed) failed << (m ? "" : m.error().message); // Base table is unconditional. - ASSERT_EQ(m->buildConfig.globFlags.size(), 1u); - EXPECT_EQ(m->buildConfig.globFlags[0].glob, "src/zlib/**"); + expect(fatal(eq(m->buildConfig.globFlags.size(), 1u))); + expect(eq(m->buildConfig.globFlags[0].glob, "src/zlib/**")); - ASSERT_EQ(m->conditionalConfigs.size(), 1u); + expect(fatal(eq(m->conditionalConfigs.size(), 1u))); auto& cc = m->conditionalConfigs[0]; - EXPECT_EQ(cc.predicate, "cfg(windows)"); - ASSERT_EQ(cc.inputs.globFlags.size(), 1u); - EXPECT_EQ(cc.inputs.globFlags[0].glob, "src/zlib/**"); - EXPECT_EQ(cc.inputs.globFlags[0].defines, - (std::vector{"NO_FSEEKO"})); + expect(eq(cc.predicate, "cfg(windows)")); + expect(fatal(eq(cc.inputs.globFlags.size(), 1u))); + expect(eq(cc.inputs.globFlags[0].glob, "src/zlib/**")); + expect(eq(cc.inputs.globFlags[0].defines, (std::vector{"NO_FSEEKO"}))); // The removal case: a windows-only -U that must land AFTER the base -D. - EXPECT_EQ(cc.inputs.globFlags[0].cflags, - (std::vector{"-UHAVE_UNISTD_H"})); - ASSERT_EQ(cc.inputs.includeDirs.size(), 1u); - EXPECT_EQ(cc.inputs.includeDirs[0], std::filesystem::path("vendor/win/include")); + expect(eq(cc.inputs.globFlags[0].cflags, (std::vector{"-UHAVE_UNISTD_H"}))); + expect(fatal(eq(cc.inputs.includeDirs.size(), 1u))); + expect(eq(cc.inputs.includeDirs[0], std::filesystem::path("vendor/win/include"))); std::filesystem::remove_all(tmp); -} +}}; // The array-of-tables spelling must reach the same place, and must be // allowlisted by the #227 closed-grammar guard. -TEST(Manifest, ConditionalPerGlobFlagsAcceptArrayOfTablesSpelling) { +UTest conditional_per_glob_flags_accept_array_of_tables_spelling{"Manifest/ConditionalPerGlobFlagsAcceptArrayOfTablesSpelling", [] { auto tmp = std::filesystem::temp_directory_path() / "mcpp_cond_flags_aot"; std::filesystem::create_directories(tmp); auto path = tmp / "mcpp.toml"; @@ -3767,20 +3713,19 @@ defines = ["ON_LINUX=1"] )"; } auto m = mcpp::manifest::load(path); - ASSERT_TRUE(m) << (m ? "" : m.error().message); - ASSERT_EQ(m->conditionalConfigs.size(), 1u); - ASSERT_EQ(m->conditionalConfigs[0].inputs.globFlags.size(), 1u); - EXPECT_EQ(m->conditionalConfigs[0].inputs.globFlags[0].defines, - (std::vector{"ON_LINUX=1"})); + if (auto&& failed = expect(fatal(bool(m))); !failed) failed << (m ? "" : m.error().message); + expect(fatal(eq(m->conditionalConfigs.size(), 1u))); + expect(fatal(eq(m->conditionalConfigs[0].inputs.globFlags.size(), 1u))); + expect(eq(m->conditionalConfigs[0].inputs.globFlags[0].defines, (std::vector{"ON_LINUX=1"}))); std::filesystem::remove_all(tmp); -} +}}; // Parity: the xpkg descriptor's target_cfg gets the same key, and its // unknown-key policy stays a HARD ERROR. Sharing a parser must not be read // as licence to relax a grammar that was already closed (#263 tracks making // the policies consistent across sections; this test pins that #258 did not // quietly change one). -TEST(Manifest, XpkgTargetCfgCarriesPerGlobFlagsAndStillRejectsUnknownKeys) { +UTest xpkg_target_cfg_carries_per_glob_flags_and_still_rejects_unknown_keys{"Manifest/XpkgTargetCfgCarriesPerGlobFlagsAndStillRejectsUnknownKeys", [] { // Custom delimiter: the descriptor contains `)"` inside ["cfg(windows)"], // which would terminate a plain R"( ... )". const char* lua = R"LUA( @@ -3795,10 +3740,10 @@ mcpp = { } )LUA"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "p", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m) << (m ? "" : m.error().message); - ASSERT_EQ(m->conditionalConfigs.size(), 1u); - ASSERT_EQ(m->conditionalConfigs[0].inputs.globFlags.size(), 1u); - EXPECT_EQ(m->conditionalConfigs[0].inputs.globFlags[0].glob, "src/z/**"); + if (auto&& failed = expect(fatal(bool(m))); !failed) failed << (m ? "" : m.error().message); + expect(fatal(eq(m->conditionalConfigs.size(), 1u))); + expect(fatal(eq(m->conditionalConfigs[0].inputs.globFlags.size(), 1u))); + expect(eq(m->conditionalConfigs[0].inputs.globFlags[0].glob, "src/z/**")); const char* bad = R"LUA( mcpp = { @@ -3807,8 +3752,8 @@ mcpp = { } )LUA"; auto bm = mcpp::manifest::synthesize_from_xpkg_lua(bad, "p", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - EXPECT_FALSE(bm) << "unknown target_cfg key must stay a hard error"; -} + expect(!bm) << "unknown target_cfg key must stay a hard error"; +}}; // #254: the host and target platform axes are distinct types, so an API // states which one it wants and a call site must name the one it supplies. @@ -3816,7 +3761,7 @@ mcpp = { // which is exactly how every xpkg per-OS splice ended up keyed on a // compile-time HOST constant while [target.'cfg(...)'] was evaluated against // the resolved TARGET. -TEST(PlatformAxis, HostAndTargetAreNotInterchangeable) { +UTest host_and_target_are_not_interchangeable{"PlatformAxis/HostAndTargetAreNotInterchangeable", [] { using mcpp::toolchain::platform::HostPlatform; using mcpp::toolchain::platform::TargetPlatform; @@ -3831,20 +3776,20 @@ TEST(PlatformAxis, HostAndTargetAreNotInterchangeable) { // The triple vocabulary says "macos"; xpkg descriptors say "macosx". // That translation belongs to the axis, not to every call site. - EXPECT_EQ(TargetPlatform::for_os("macos").key(), "macosx"); - EXPECT_EQ(TargetPlatform::for_os("macosx").key(), "macosx"); - EXPECT_EQ(TargetPlatform::for_os("windows").key(), "windows"); - EXPECT_EQ(TargetPlatform::for_os("linux").key(), "linux"); + expect(eq(TargetPlatform::for_os("macos").key(), "macosx")); + expect(eq(TargetPlatform::for_os("macosx").key(), "macosx")); + expect(eq(TargetPlatform::for_os("windows").key(), "windows")); + expect(eq(TargetPlatform::for_os("linux").key(), "linux")); // A token naming none of the three reads its own block, never the // host's (design 2026-10-29 §5.1, #851). - EXPECT_EQ(TargetPlatform::for_os("emscripten").key(), "emscripten"); - EXPECT_EQ(TargetPlatform::for_os("none").key(), "none"); -} + expect(eq(TargetPlatform::for_os("emscripten").key(), "emscripten")); + expect(eq(TargetPlatform::for_os("none").key(), "none")); +}}; // The behaviour half: a descriptor's per-OS section must be spliced for the // TARGET. Native builds cannot observe this (host == target), which is why // three-platform CI never caught it. -TEST(Manifest, XpkgPerOsSectionSplicesForTheTargetNotTheHost) { +UTest xpkg_per_os_section_splices_for_the_target_not_the_host{"Manifest/XpkgPerOsSectionSplicesForTheTargetNotTheHost", [] { const char* lua = R"LUA( mcpp = { sources = { "base.cpp" }, @@ -3864,23 +3809,23 @@ mcpp = { {"windows", "windows_only.cpp"}}) { auto m = mcpp::manifest::synthesize_from_xpkg_lua( lua, "p", "1.0.0", mcpp::toolchain::platform::TargetPlatform::for_os(tripleOs)); - ASSERT_TRUE(m) << (m ? "" : m.error().message); - EXPECT_TRUE(has(*m, expected)) + if (auto&& failed = expect(fatal(bool(m))); !failed) failed << (m ? "" : m.error().message); + expect(bool(has(*m, expected))) << "target os " << tripleOs << " must splice its own section"; - EXPECT_TRUE(has(*m, "base.cpp")); + expect(bool(has(*m, "base.cpp"))); // and must NOT carry the other legs for (std::string_view other : {"linux_only.cpp", "macos_only.cpp", "windows_only.cpp"}) { if (other == expected) continue; - EXPECT_FALSE(has(*m, other)) + expect(!has(*m, other)) << "target os " << tripleOs << " must not carry " << other; } } -} +}}; // Zero behaviour change for native builds is the safety net for #254: when // host == target the splice must be byte-identical to what it always was. -TEST(Manifest, NativeBuildSpliceIsUnchanged) { +UTest native_build_splice_is_unchanged{"Manifest/NativeBuildSpliceIsUnchanged", [] { const char* lua = R"LUA( mcpp = { sources = { "base.cpp" }, @@ -3895,13 +3840,13 @@ mcpp = { lua, "p", "1.0.0", mcpp::toolchain::platform::TargetPlatform::for_lint_of( mcpp::toolchain::platform::HostPlatform::current().key())); - ASSERT_TRUE(host); - ASSERT_TRUE(nativeTarget); - EXPECT_EQ(host->modules.sources, nativeTarget->modules.sources); - EXPECT_EQ(host->buildConfig.cxxflags, nativeTarget->buildConfig.cxxflags); -} + expect(fatal(bool(host))); + expect(fatal(bool(nativeTarget))); + expect(eq(host->modules.sources, nativeTarget->modules.sources)); + expect(eq(host->buildConfig.cxxflags, nativeTarget->buildConfig.cxxflags)); +}}; -TEST(Manifest, IndicesArtifactAndSourceParsedAndPinSuppresses) { +UTest indices_artifact_and_source_parsed_and_pin_suppresses{"Manifest/IndicesArtifactAndSourceParsedAndPinSuppresses", [] { // #269: [indices] entries may declare an xlings >= 0.4.68 artifact // source. rev/tag/branch pins force git (the artifact channel only // tracks the latest pointer), which artifact_applicable() encodes. @@ -3918,22 +3863,22 @@ fast = { url = "https://example.com/i.git", artifact = "https://example.com/re pinned = { url = "https://example.com/i.git", artifact = "https://example.com/res/i", rev = "abc123" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto& fast = m->indices.at("fast"); - EXPECT_EQ(fast.artifact, "https://example.com/res/i"); - EXPECT_EQ(fast.source, "auto"); - EXPECT_TRUE(fast.artifact_applicable()); + expect(eq(fast.artifact, "https://example.com/res/i")); + expect(eq(fast.source, "auto")); + expect(bool(fast.artifact_applicable())); auto& pinned = m->indices.at("pinned"); - EXPECT_EQ(pinned.artifact, "https://example.com/res/i"); - EXPECT_FALSE(pinned.artifact_applicable()); -} + expect(eq(pinned.artifact, "https://example.com/res/i")); + expect(!pinned.artifact_applicable()); +}}; // #359: `reexport = true` on a dependency edge — the knob that lets a library // hand its build-time provisions (tools, host module, dependency dir) to its // own consumers. Off by default, deliberately: the edge's `visibility` already // defaults to "public", so riding that would have made every dependency at // every depth able to push entries into a consumer's tool namespace silently. -TEST(Manifest, DependencyReexportIsOptIn) { +UTest dependency_reexport_is_opt_in{"Manifest/DependencyReexportIsOptIn", [] { auto tmp = std::filesystem::temp_directory_path() / "mcpp_dep_reexport"; std::filesystem::create_directories(tmp); auto path = tmp / "mcpp.toml"; @@ -3950,7 +3895,7 @@ zlib = { version = "1.3.1", tools = ["minigzip"] } )"; } auto m = mcpp::manifest::load(path); - ASSERT_TRUE(m) << (m ? "" : m.error().message); + if (auto&& failed = expect(fatal(bool(m))); !failed) failed << (m ? "" : m.error().message); const mcpp::pm::DependencySpec* pb = nullptr; const mcpp::pm::DependencySpec* zl = nullptr; @@ -3958,11 +3903,11 @@ zlib = { version = "1.3.1", tools = ["minigzip"] } if (spec.shortName == "protobuf") pb = &spec; if (spec.shortName == "zlib") zl = &spec; } - ASSERT_NE(pb, nullptr); - ASSERT_NE(zl, nullptr); - EXPECT_TRUE(pb->reexport); - EXPECT_FALSE(zl->reexport); -} + expect(fatal(ne(pb, nullptr))); + expect(fatal(ne(zl, nullptr))); + expect(bool(pb->reexport)); + expect(!zl->reexport); +}}; // #359 D3a: the conditional channel carried three of the four dependency maps // and silently lacked `feature-deps`. A library that puts a host tool behind a @@ -3973,7 +3918,7 @@ zlib = { version = "1.3.1", tools = ["minigzip"] } // The FEATURE is registered regardless of the predicate — only what it pulls // in is conditional — so requesting it on a non-matching platform is not an // unknown-feature error. -TEST(Manifest, ConditionalFeatureDepsAreParsedAndTheFeatureIsRegistered) { +UTest conditional_feature_deps_are_parsed_and_the_feature_is_registered{"Manifest/ConditionalFeatureDepsAreParsedAndTheFeatureIsRegistered", [] { auto tmp = std::filesystem::temp_directory_path() / "mcpp_cond_feature_deps"; std::filesystem::create_directories(tmp); auto path = tmp / "mcpp.toml"; @@ -3989,24 +3934,24 @@ version = "0.1.0" )"; } auto m = mcpp::manifest::load(path); - ASSERT_TRUE(m) << (m ? "" : m.error().message); + if (auto&& failed = expect(fatal(bool(m))); !failed) failed << (m ? "" : m.error().message); - EXPECT_TRUE(m->featuresMap.contains("codegen")); + expect(m->featuresMap.contains("codegen")); // Not folded into the unconditional map: that happens in prepare, after // the predicate is evaluated against the RESOLVED target. - EXPECT_TRUE(m->featureDeps.empty()); + expect(m->featureDeps.empty()); - ASSERT_EQ(m->conditionalConfigs.size(), 1u); + expect(fatal(eq(m->conditionalConfigs.size(), 1u))); auto const& cc = m->conditionalConfigs[0]; - EXPECT_EQ(cc.predicate, "cfg(not(windows))"); - ASSERT_TRUE(cc.featureDeps.contains("codegen")); - ASSERT_EQ(cc.featureDeps.at("codegen").size(), 1u); + expect(eq(cc.predicate, "cfg(not(windows))")); + expect(fatal(cc.featureDeps.contains("codegen"))); + expect(fatal(eq(cc.featureDeps.at("codegen").size(), 1u))); auto const& spec = cc.featureDeps.at("codegen").begin()->second; - EXPECT_EQ(spec.shortName, "protobuf"); - ASSERT_EQ(spec.tools.size(), 1u); - EXPECT_EQ(spec.tools[0], "protoc"); - EXPECT_TRUE(spec.reexport); -} + expect(eq(spec.shortName, "protobuf")); + expect(fatal(eq(spec.tools.size(), 1u))); + expect(eq(spec.tools[0], "protoc")); + expect(bool(spec.reexport)); +}}; // A dependency-spec key must be listed in TWO places in the TOML reader: the // predicate that tells an inline spec from a nested namespace table, and the @@ -4017,7 +3962,7 @@ version = "0.1.0" // // One manifest using every key at once is the cheapest way to keep the two in // sync: a key added to the filler but not the predicate fails this test. -TEST(Manifest, EveryDependencySpecKeyIsAccepted) { +UTest every_dependency_spec_key_is_accepted{"Manifest/EveryDependencySpecKeyIsAccepted", [] { auto tmp = std::filesystem::temp_directory_path() / "mcpp_dep_spec_keys"; std::filesystem::create_directories(tmp); auto path = tmp / "mcpp.toml"; @@ -4035,39 +3980,37 @@ bypath = { path = "../sibling" } )"; } auto m = mcpp::manifest::load(path); - ASSERT_TRUE(m) << (m ? "" : m.error().message); + if (auto&& failed = expect(fatal(bool(m))); !failed) failed << (m ? "" : m.error().message); const mcpp::pm::DependencySpec* all = nullptr; for (auto const& [k, spec] : m->dependencies) if (spec.shortName == "everything") all = &spec; - ASSERT_NE(all, nullptr); - EXPECT_EQ(all->version, "1.0.0"); - EXPECT_EQ(all->visibility, "private"); - EXPECT_FALSE(all->defaultFeatures); - EXPECT_TRUE(all->hostModule); - EXPECT_TRUE(all->reexport); - ASSERT_EQ(all->tools.size(), 1u); - EXPECT_EQ(all->tools[0], "t"); - ASSERT_EQ(all->artifacts.size(), 1u); - EXPECT_EQ(all->artifacts[0], "a"); + expect(fatal(ne(all, nullptr))); + expect(eq(all->version, "1.0.0")); + expect(eq(all->visibility, "private")); + expect(!all->defaultFeatures); + expect(bool(all->hostModule)); + expect(bool(all->reexport)); + expect(fatal(eq(all->tools.size(), 1u))); + expect(eq(all->tools[0], "t")); + expect(fatal(eq(all->artifacts.size(), 1u))); + expect(eq(all->artifacts[0], "a")); // `backend = "openblas"` is sugar for requesting the backend- feature. - EXPECT_NE(std::find(all->features.begin(), all->features.end(), "backend-openblas"), - all->features.end()); - EXPECT_EQ(all->linkage, "shared"); + expect(ne(std::find(all->features.begin(), all->features.end(), "backend-openblas"), all->features.end())); + expect(eq(all->linkage, "shared")); // design 2026-11-02 §14.1: a patch is named relative to the manifest that // carries it, and read as an absolute path. - ASSERT_EQ(all->patches.size(), 1u); - EXPECT_EQ(std::filesystem::path(all->patches[0]), - (tmp / "p" / "one.patch").lexically_normal()); - EXPECT_TRUE(all->embed); - ASSERT_EQ(all->exclude.size(), 1u); - EXPECT_EQ(all->exclude[0], "src/prim.c"); -} + expect(fatal(eq(all->patches.size(), 1u))); + expect(eq(std::filesystem::path(all->patches[0]), (tmp / "p" / "one.patch").lexically_normal())); + expect(bool(all->embed)); + expect(fatal(eq(all->exclude.size(), 1u))); + expect(eq(all->exclude[0], "src/prim.c")); +}}; // design 2026-11-02 §14.1: `exclude` without `embed` would change what a // library built on its own compiles for every consumer, which is a patch's to // state; the manifest is refused, naming the edge. -TEST(Manifest, ExcludeWithoutEmbedIsRefused) { +UTest exclude_without_embed_is_refused{"Manifest/ExcludeWithoutEmbedIsRefused", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "x" @@ -4076,10 +4019,9 @@ version = "0.1.0" [dependencies] "compat.zlib" = { version = "1.3.2", exclude = ["gzlib.c"] } )"); - ASSERT_FALSE(m); - EXPECT_NE(m.error().message.find("exclude is allowed only with embed"), std::string::npos) - << m.error().message; -} + expect(fatal(!m)); + if (auto&& failed = expect(ne(m.error().message.find("exclude is allowed only with embed"), std::string::npos)); !failed) failed << m.error().message; +}}; // ── #519: the dependency-form axis ──────────────────────────────────────── @@ -4097,17 +4039,17 @@ load_inline(std::string_view name, std::string_view body) { } // namespace -TEST(Manifest, DependencyLinkageDefaultsToEmptyMeaningStatic) { +UTest dependency_linkage_defaults_to_empty_meaning_static{"Manifest/DependencyLinkageDefaultsToEmptyMeaningStatic", [] { auto m = load_inline("deplinkage_default", R"( [package] name = "x" version = "0.1.0" )"); - ASSERT_TRUE(m); - EXPECT_TRUE(m->buildConfig.dependencyLinkage.empty()); -} + expect(fatal(bool(m))); + expect(m->buildConfig.dependencyLinkage.empty()); +}}; -TEST(Manifest, DependencyLinkageIsAClosedVocabulary) { +UTest dependency_linkage_is_a_closed_vocabulary{"Manifest/DependencyLinkageIsAClosedVocabulary", [] { auto ok = load_inline("deplinkage_ok", R"( [package] name = "x" @@ -4115,8 +4057,8 @@ version = "0.1.0" [build] dependency_linkage = "shared" )"); - ASSERT_TRUE(ok); - EXPECT_EQ(ok->buildConfig.dependencyLinkage, "shared"); + expect(fatal(bool(ok))); + expect(eq(ok->buildConfig.dependencyLinkage, "shared")); // "dynamic" is the LIBC axis' word. Accepting it here would silently // mean "static", because that is what an unparsed value resolves to. @@ -4127,10 +4069,10 @@ version = "0.1.0" [build] dependency_linkage = "dynamic" )"); - EXPECT_FALSE(bad); -} + expect(!bad); +}}; -TEST(Manifest, ProfileDependencyLinkageIsOptionalSoItDoesNotResetBuild) { +UTest profile_dependency_linkage_is_optional_so_it_does_not_reset_build{"Manifest/ProfileDependencyLinkageIsOptionalSoItDoesNotResetBuild", [] { // Resolving a profile REPLACES the whole Profile struct, so a plain field // would make any declared profile silently undo `[build]`. auto m = load_inline("deplinkage_profile", R"( @@ -4144,15 +4086,15 @@ opt = "0" [profile.fast] dependency_linkage = "static" )"); - ASSERT_TRUE(m); - ASSERT_TRUE(m->profiles.contains("dev")); - EXPECT_FALSE(m->profiles.at("dev").dependencyLinkageDeclared); - ASSERT_TRUE(m->profiles.contains("fast")); - ASSERT_TRUE(m->profiles.at("fast").dependencyLinkageDeclared); - EXPECT_EQ(m->profiles.at("fast").dependencyLinkage, "static"); -} - -TEST(Manifest, ADependencyEdgeLinkageIsAClosedVocabularyToo) { + expect(fatal(bool(m))); + expect(fatal(m->profiles.contains("dev"))); + expect(!(m->profiles.at("dev").dependencyLinkageDeclared)); + expect(fatal(m->profiles.contains("fast"))); + expect(fatal(bool(m->profiles.at("fast").dependencyLinkageDeclared))); + expect(eq(m->profiles.at("fast").dependencyLinkage, "static")); +}}; + +UTest a_dependency_edge_linkage_is_a_closed_vocabulary_too{"Manifest/ADependencyEdgeLinkageIsAClosedVocabularyToo", [] { auto bad = load_inline("edge_linkage_bad", R"( [package] name = "x" @@ -4160,10 +4102,10 @@ version = "0.1.0" [dependencies] zlib = { version = "1.0.0", linkage = "dynamic" } )"); - EXPECT_FALSE(bad); -} + expect(!bad); +}}; -TEST(Manifest, ALibraryTargetMayDeclareASoname) { +UTest a_library_target_may_declare_a_soname{"Manifest/ALibraryTargetMayDeclareASoname", [] { // This used to make the WHOLE MANIFEST FAIL TO LOAD, in both parsers. // A soname is the name a library is FOUND by, and it is the only way two // copies of one library can resolve to a single file — so a package has @@ -4177,12 +4119,12 @@ version = "0.1.0" kind = "lib" soname = "libz.so.1" )"); - ASSERT_TRUE(m) << (m ? "" : m.error().message); - ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets[0].soname, "libz.so.1"); -} + if (auto&& failed = expect(fatal(bool(m))); !failed) failed << (m ? "" : m.error().message); + expect(fatal(eq(m->targets.size(), 1u))); + expect(eq(m->targets[0].soname, "libz.so.1")); +}}; -TEST(Manifest, ASonameOnABinaryTargetIsStillRefused) { +UTest a_soname_on_a_binary_target_is_still_refused{"Manifest/ASonameOnABinaryTargetIsStillRefused", [] { auto m = load_inline("soname_on_bin", R"( [package] name = "z" @@ -4192,10 +4134,10 @@ kind = "bin" main = "src/main.cpp" soname = "libz.so.1" )"); - EXPECT_FALSE(m); -} + expect(!m); +}}; -TEST(Manifest, ASonameMustStillBeABasename) { +UTest a_soname_must_still_be_a_basename{"Manifest/ASonameMustStillBeABasename", [] { auto m = load_inline("soname_path", R"( [package] name = "z" @@ -4204,8 +4146,8 @@ version = "0.1.0" kind = "lib" soname = "lib/libz.so.1" )"); - EXPECT_FALSE(m); -} + expect(!m); +}}; // #359: a manifest written for a NEWER mcpp must still load. // @@ -4220,7 +4162,7 @@ soname = "lib/libz.so.1" // The discriminator is now "does it name a source", which a namespace table // cannot accidentally satisfy, and an unrecognized key is a recorded // degradation (--strict still refuses it). -TEST(Manifest, UnknownDependencyKeyDegradesInsteadOfFailingTheLoad) { +UTest unknown_dependency_key_degrades_instead_of_failing_the_load{"Manifest/UnknownDependencyKeyDegradesInsteadOfFailingTheLoad", [] { auto tmp = std::filesystem::temp_directory_path() / "mcpp_dep_future_key"; std::filesystem::create_directories(tmp); auto path = tmp / "mcpp.toml"; @@ -4236,24 +4178,24 @@ zlib = { version = "1.3.1", some-future-field = true } )"; } auto m = mcpp::manifest::load(path); - ASSERT_TRUE(m) << (m ? "" : m.error().message); + if (auto&& failed = expect(fatal(bool(m))); !failed) failed << (m ? "" : m.error().message); const mcpp::pm::DependencySpec* z = nullptr; for (auto const& [k, spec] : m->dependencies) if (spec.shortName == "zlib") z = &spec; - ASSERT_NE(z, nullptr); - EXPECT_EQ(z->version, "1.3.1"); // the part this reader understands still applies + expect(fatal(ne(z, nullptr))); + expect(eq(z->version, "1.3.1")); // the part this reader understands still applies bool warned = false; for (auto const& w : m->schemaWarnings) if (w.find("some-future-field") != std::string::npos) warned = true; - EXPECT_TRUE(warned) << "the ignored key must be reported, not swallowed"; -} + expect(bool(warned)) << "the ignored key must be reported, not swallowed"; +}}; // The discriminator must not mistake a namespace subtable for an inline spec. // `[dependencies.compat] zlib = "1.3.1"` has one key, `zlib`, which names no // source — so it is a namespace, and the entry below it is the dependency. -TEST(Manifest, NamespaceSubtableIsNotMistakenForAnInlineSpec) { +UTest namespace_subtable_is_not_mistaken_for_an_inline_spec{"Manifest/NamespaceSubtableIsNotMistakenForAnInlineSpec", [] { auto tmp = std::filesystem::temp_directory_path() / "mcpp_ns_vs_spec"; std::filesystem::create_directories(tmp); auto path = tmp / "mcpp.toml"; @@ -4270,20 +4212,20 @@ gtest = { version = "1.15.2", features = ["main"] } )"; } auto m = mcpp::manifest::load(path); - ASSERT_TRUE(m) << (m ? "" : m.error().message); + if (auto&& failed = expect(fatal(bool(m))); !failed) failed << (m ? "" : m.error().message); std::set got; for (auto const& [k, spec] : m->dependencies) { got.insert(spec.shortName); - EXPECT_EQ(spec.namespace_, "compat") << spec.shortName; + expect(eq(spec.namespace_, "compat")) << spec.shortName; } - EXPECT_TRUE(got.contains("zlib")); - EXPECT_TRUE(got.contains("gtest")); -} + expect(got.contains("zlib")); + expect(got.contains("gtest")); +}}; // `cxx_runtime` gained a third role key. The table is validated by an explicit // whitelist, so a new spelling that nobody added there is rejected with a // message naming the key — which is the behaviour, not an accident. -TEST(Manifest, CxxRuntimeAcceptsThePerRoleTableIncludingShared) { +UTest cxx_runtime_accepts_the_per_role_table_including_shared{"Manifest/CxxRuntimeAcceptsThePerRoleTableIncludingShared", [] { constexpr auto src = R"( [package] name = "app" @@ -4292,18 +4234,18 @@ version = "0.1.0" cxx_runtime = { default = "self-contained", tests = "host-coupled", shared = "toolchain-coupled" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->buildConfig.cxxRuntime, "self-contained"); - EXPECT_EQ(m->buildConfig.cxxRuntimeTests, "host-coupled"); - EXPECT_EQ(m->buildConfig.cxxRuntimeShared, "toolchain-coupled"); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->buildConfig.cxxRuntime, "self-contained")); + expect(eq(m->buildConfig.cxxRuntimeTests, "host-coupled")); + expect(eq(m->buildConfig.cxxRuntimeShared, "toolchain-coupled")); // And it must not be reported as an unsupported [build] key. The whole // feature is reachable only through this spelling, so a warning saying it // is "ignored" would be both noisy and false. - EXPECT_TRUE(m->schemaWarnings.empty()) + expect(m->schemaWarnings.empty()) << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); -} +}}; -TEST(Manifest, CxxRuntimeRejectsAnUnknownRoleKey) { +UTest cxx_runtime_rejects_an_unknown_role_key{"Manifest/CxxRuntimeRejectsAnUnknownRoleKey", [] { constexpr auto src = R"( [package] name = "app" @@ -4312,13 +4254,12 @@ version = "0.1.0" cxx_runtime = { sharedd = "self-contained" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().format().find("sharedd"), std::string::npos) - << m.error().format(); -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().format().find("sharedd"), std::string::npos)); !failed) failed << m.error().format(); +}}; // The scalar spelling still means "every role", including shared libraries. -TEST(Manifest, CxxRuntimeScalarStillAppliesToEveryRole) { +UTest cxx_runtime_scalar_still_applies_to_every_role{"Manifest/CxxRuntimeScalarStillAppliesToEveryRole", [] { constexpr auto src = R"( [package] name = "app" @@ -4327,13 +4268,13 @@ version = "0.1.0" cxx_runtime = "host-coupled" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->buildConfig.cxxRuntime, "host-coupled"); - EXPECT_TRUE(m->buildConfig.cxxRuntimeShared.empty()); - EXPECT_TRUE(m->schemaWarnings.empty()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->buildConfig.cxxRuntime, "host-coupled")); + expect(m->buildConfig.cxxRuntimeShared.empty()); + expect(m->schemaWarnings.empty()); +}}; -TEST(Manifest, BmiScheduleKey) { +UTest bmi_schedule_key{"Manifest/BmiScheduleKey", [] { // The KEY SPELLING, not just the field. `[build] bmi_schedule` reaches the // build only through this one string in mcpp.manifest.toml, and nothing // downstream can tell a mistyped key from an absent one: the value silently @@ -4351,8 +4292,8 @@ version = "0.1.0" bmi_schedule = "on" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->buildConfig.bmiSchedule, "on"); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->buildConfig.bmiSchedule, "on")); // And the old spelling must NOT still work: leaving it accepted would mean // two keys for one switch, which is how a rename ends up half-done. @@ -4364,8 +4305,8 @@ version = "0.1.0" schedule = "on" )"; auto o = mcpp::manifest::parse_string(old_key); - ASSERT_TRUE(o.has_value()) << o.error().format(); - EXPECT_TRUE(o->buildConfig.bmiSchedule.empty()) + if (auto&& failed = expect(fatal(o.has_value())); !failed) failed << o.error().format(); + expect(o->buildConfig.bmiSchedule.empty()) << "the pre-rename key `schedule` is still being read"; // AND THE PARSER MUST NOT WARN ABOUT ITS OWN KEY. @@ -4381,16 +4322,16 @@ schedule = "on" // feature on announced that it had been ignored, and the dead `schedule` // was accepted in silence. Both halves of the rename are pinned now. for (const auto& w : m->schemaWarnings) - EXPECT_EQ(w.find("bmi_schedule"), std::string::npos) + expect(eq(w.find("bmi_schedule"), std::string::npos)) << "the parser reads `bmi_schedule` but also warns about it: " << w; - EXPECT_TRUE(std::ranges::any_of(o->schemaWarnings, + expect(std::ranges::any_of(o->schemaWarnings, [](const std::string& w) { return w.find("schedule") != std::string::npos; })) << "the dead key `schedule` is accepted silently — an unread key that " "produces no diagnostic is a typo that costs a debugging session"; -} +}}; // #418 — an unsupported SCALAR under `[target.]` is reported; the // conditional sub-TABLES are not. @@ -4408,7 +4349,7 @@ schedule = "on" // own comments on #258 and #359). The first version of this check did exactly // that and warned about a documented feature. Scalars only; tables are skipped // on principle, not by enumeration. -TEST(Manifest, PerTargetUnknownScalarWarnsButSubTablesDoNot) { +UTest per_target_unknown_scalar_warns_but_sub_tables_do_not{"Manifest/PerTargetUnknownScalarWarnsButSubTablesDoNot", [] { constexpr auto src = R"( [package] name = "t" @@ -4425,7 +4366,7 @@ cxx_runtime_tests = "host-coupled" defines = ["FOO=1"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto mentions = [&](std::string_view needle) { return std::ranges::any_of(m->schemaWarnings, [&](const std::string& w) { @@ -4433,23 +4374,22 @@ defines = ["FOO=1"] }); }; - EXPECT_TRUE(mentions("cxx_runtime_tests")) + expect(bool(mentions("cxx_runtime_tests"))) << "a per-target scalar that nothing reads must be reported, not dropped"; // The conditional channel is not a typo, and warning about it would be a // false positive on a documented feature (e2e 195 uses it). - EXPECT_FALSE(mentions("'dependencies'")) + expect(!mentions("'dependencies'")) << "[target..dependencies] is the conditional channel, not an unknown key"; - EXPECT_FALSE(mentions("'build'")) - << "[target..build] is the conditional channel, not an unknown key"; + if (auto&& failed = expect(!mentions("'build'")); !failed) failed << "[target..build] is the conditional channel, not an unknown key"; // ...and the field itself is gone, so nothing downstream can read it. - EXPECT_EQ(m->targetOverrides.at("x86_64-linux-gnu").cxxRuntime, ""); -} + expect(eq(m->targetOverrides.at("x86_64-linux-gnu").cxxRuntime, "")); +}}; // ── [target.].runner ──────────────────────────────────────────────── -TEST(Manifest, TargetRunnerParsesAndDoesNotWarn) { +UTest target_runner_parses_and_does_not_warn{"Manifest/TargetRunnerParsesAndDoesNotWarn", [] { constexpr auto src = R"( [package] name = "fw" @@ -4459,21 +4399,21 @@ version = "0.1.0" runner = ["qemu-system-riscv64", "-machine", "virt", "-kernel"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto it = m->targetOverrides.find("riscv64-none-elf"); - ASSERT_NE(it, m->targetOverrides.end()); - ASSERT_EQ(it->second.runner.size(), 4u); - EXPECT_EQ(it->second.runner.front(), "qemu-system-riscv64"); - EXPECT_EQ(it->second.runner.back(), "-kernel"); + expect(fatal(ne(it, m->targetOverrides.end()))); + expect(fatal(eq(it->second.runner.size(), 4u))); + expect(eq(it->second.runner.front(), "qemu-system-riscv64")); + expect(eq(it->second.runner.back(), "-kernel")); // The unknown-key sweep is about SCALARS ("a scalar that does // nothing"). It skipped tables but not arrays, so this key was reported as // "unsupported key 'runner' (ignored)" while in fact being honoured — // worse than either statement being true. Seen in CI. - EXPECT_TRUE(m->schemaWarnings.empty()) + expect(m->schemaWarnings.empty()) << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); -} +}}; -TEST(Manifest, TargetRunnerRejectsShapesThatWouldRunNothing) { +UTest target_runner_rejects_shapes_that_would_run_nothing{"Manifest/TargetRunnerRejectsShapesThatWouldRunNothing", [] { // An empty template would exec nothing and report success. constexpr auto empty = R"( [package] @@ -4482,7 +4422,7 @@ version = "0.1.0" [target.riscv64-none-elf] runner = [] )"; - EXPECT_FALSE(mcpp::manifest::parse_string(empty).has_value()); + expect(!(mcpp::manifest::parse_string(empty).has_value())); // A bare string is the shape a user reaches for first; taking it would // mean guessing where the word boundaries are. @@ -4493,8 +4433,8 @@ version = "0.1.0" [target.riscv64-none-elf] runner = "qemu-system-riscv64 -kernel" )"; - EXPECT_FALSE(mcpp::manifest::parse_string(scalar).has_value()); -} + expect(!(mcpp::manifest::parse_string(scalar).has_value())); +}}; // ── [target.].sysroot — the target's C library, per project ────────── // @@ -4503,7 +4443,7 @@ runner = "qemu-system-riscv64 -kernel" // project on a vendor SDK wants that SDK's newlib, and a `std-freestanding` // stack over openkal wants openkal's C library. -TEST(Manifest, TargetSysrootOverrideIsParsed) { +UTest target_sysroot_override_is_parsed{"Manifest/TargetSysrootOverrideIsParsed", [] { constexpr auto src = R"( [package] name = "x" @@ -4512,18 +4452,17 @@ version = "0.1.0" sysroot = "xim:newlib-riscv@4.4" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto it = m->targetOverrides.find("riscv64-none-elf"); - ASSERT_NE(it, m->targetOverrides.end()); - ASSERT_TRUE(it->second.sysrootDeclared); - EXPECT_EQ(it->second.sysroot, "xim:newlib-riscv@4.4"); - EXPECT_TRUE(m->schemaWarnings.empty()) - << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); -} + expect(fatal(ne(it, m->targetOverrides.end()))); + expect(fatal(bool(it->second.sysrootDeclared))); + expect(eq(it->second.sysroot, "xim:newlib-riscv@4.4")); + if (auto&& failed = expect(m->schemaWarnings.empty()); !failed) failed << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); +}}; // The distinction this test pins is the reason the field is an optional. // An empty string is a REQUEST (no C library), not an absence. -TEST(Manifest, TargetSysrootEmptyStringIsRecordedAsPresent) { +UTest target_sysroot_empty_string_is_recorded_as_present{"Manifest/TargetSysrootEmptyStringIsRecordedAsPresent", [] { constexpr auto src = R"( [package] name = "x" @@ -4532,14 +4471,14 @@ version = "0.1.0" sysroot = "" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto it = m->targetOverrides.find("riscv64-none-elf"); - ASSERT_NE(it, m->targetOverrides.end()); - ASSERT_TRUE(it->second.sysrootDeclared); - EXPECT_TRUE(it->second.sysroot.empty()); -} + expect(fatal(ne(it, m->targetOverrides.end()))); + expect(fatal(bool(it->second.sysrootDeclared))); + expect(it->second.sysroot.empty()); +}}; -TEST(Manifest, TargetSysrootAbsentIsNotDeclared) { +UTest target_sysroot_absent_is_not_declared{"Manifest/TargetSysrootAbsentIsNotDeclared", [] { constexpr auto src = R"( [package] name = "x" @@ -4548,15 +4487,15 @@ version = "0.1.0" linkage = "static" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto it = m->targetOverrides.find("riscv64-none-elf"); - ASSERT_NE(it, m->targetOverrides.end()); - EXPECT_FALSE(it->second.sysrootDeclared); -} + expect(fatal(ne(it, m->targetOverrides.end()))); + expect(!(it->second.sysrootDeclared)); +}}; // A bare name is the plausible typo, and accepting it would install nothing // and then fail much later naming a missing libc. -TEST(Manifest, TargetSysrootRejectsANonXpkgReference) { +UTest target_sysroot_rejects_a_non_xpkg_reference{"Manifest/TargetSysrootRejectsANonXpkgReference", [] { constexpr auto src = R"( [package] name = "x" @@ -4565,13 +4504,13 @@ version = "0.1.0" sysroot = "newlib" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().format().find("is not a sysroot"), std::string::npos); -} + expect(fatal(!m.has_value())); + expect(ne(m.error().format().find("is not a sysroot"), std::string::npos)); +}}; // The table form names a directory, absolute against the manifest's own // directory, and carries what it supplies (design 2026-10-23 §3.1). -TEST(Manifest, TargetSysrootTableNamesADirectory) { +UTest target_sysroot_table_names_a_directory{"Manifest/TargetSysrootTableNamesADirectory", [] { constexpr auto src = R"( [package] name = "x" @@ -4583,19 +4522,18 @@ run_under = ["remote-run", "team-mac", "--"] // An absolute origin on this host (a drive letter on Windows). const auto proj = (std::filesystem::temp_directory_path() / "proj").lexically_normal(); auto m = mcpp::manifest::parse_string(src, proj / "mcpp.toml"); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto it = m->targetOverrides.find("x86_64-linux-gnu"); - ASSERT_NE(it, m->targetOverrides.end()); - EXPECT_TRUE(it->second.sysrootDeclared); - EXPECT_EQ(it->second.sysroot, - "path:" + (proj / "third_party" / "sysroot").lexically_normal().generic_string()); - ASSERT_EQ(it->second.sysrootProvides.size(), 1u); - EXPECT_EQ(it->second.runUnder, (std::vector{"remote-run", "team-mac", "--"})); + expect(fatal(ne(it, m->targetOverrides.end()))); + expect(bool(it->second.sysrootDeclared)); + expect(eq(it->second.sysroot, "path:" + (proj / "third_party" / "sysroot").lexically_normal().generic_string())); + expect(fatal(eq(it->second.sysrootProvides.size(), 1u))); + expect(eq(it->second.runUnder, (std::vector{"remote-run", "team-mac", "--"}))); for (auto const& w : m->schemaWarnings) - EXPECT_EQ(w.find("sysroot"), std::string::npos) << w; -} + expect(eq(w.find("sysroot"), std::string::npos)) << w; +}}; -TEST(Manifest, TargetSysrootTableRefusesOtherKeys) { +UTest target_sysroot_table_refuses_other_keys{"Manifest/TargetSysrootTableRefusesOtherKeys", [] { constexpr auto src = R"( [package] name = "x" @@ -4604,13 +4542,13 @@ version = "0.1.0" sysroot = { path = "/opt/s", libc = "glibc" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().format().find("`path`"), std::string::npos) << m.error().format(); -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().format().find("`path`"), std::string::npos)); !failed) failed << m.error().format(); +}}; // The unknown-scalar sweep must not report a key it now honours — the exact // shape of #418, where a key was reported as ignored while being applied. -TEST(Manifest, TargetSysrootIsNotReportedAsUnsupported) { +UTest target_sysroot_is_not_reported_as_unsupported{"Manifest/TargetSysrootIsNotReportedAsUnsupported", [] { constexpr auto src = R"( [package] name = "x" @@ -4619,10 +4557,10 @@ version = "0.1.0" sysroot = "" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); for (auto const& w : m->schemaWarnings) - EXPECT_EQ(w.find("sysroot"), std::string::npos) << w; -} + expect(eq(w.find("sysroot"), std::string::npos)) << w; +}}; // ── On an MSVC-ABI row the sysroot is an MSVC toolset ───────────────────────── // @@ -4643,39 +4581,38 @@ sysroot = "{}" } } // namespace -TEST(Manifest, MsvcRowSysrootTakesTheMsvcSpellings) { +UTest msvc_row_sysroot_takes_the_msvc_spellings{"Manifest/MsvcRowSysrootTakesTheMsvcSpellings", [] { for (auto triple : {"x86_64-windows-msvc", "x86_64-pc-windows-msvc"}) { for (auto value : {"msvc@system", "msvc@14.44.35207", "msvc@14.44", "xim:msvc@14.44.35207"}) { auto m = msvc_row(triple, value); - ASSERT_TRUE(m.has_value()) << triple << " " << value << ": " + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << triple << " " << value << ": " << m.error().format(); auto it = m->targetOverrides.find(triple); - ASSERT_NE(it, m->targetOverrides.end()); - ASSERT_TRUE(it->second.sysrootDeclared); - EXPECT_EQ(it->second.sysroot, value); + expect(fatal(ne(it, m->targetOverrides.end()))); + expect(fatal(bool(it->second.sysrootDeclared))); + expect(eq(it->second.sysroot, value)); } } -} +}}; -TEST(Manifest, MsvcRowSysrootRefusesWhatIsNotAnMsvcToolset) { +UTest msvc_row_sysroot_refuses_what_is_not_an_msvc_toolset{"Manifest/MsvcRowSysrootRefusesWhatIsNotAnMsvcToolset", [] { // A C library package, the zero-libc tier, a package asked for the // machine's toolset, and a bare family name are all refused by name. for (auto value : {"xim:glibc@2.39", "", "xim:msvc@system", "msvc", "gcc@16.1.0"}) { auto m = msvc_row("x86_64-windows-msvc", value); - ASSERT_FALSE(m.has_value()) << "'" << value << "' was accepted"; - EXPECT_NE(m.error().format().find("MSVC toolset"), std::string::npos) - << m.error().format(); + expect(fatal(!m.has_value())) << "'" << value << "' was accepted"; + if (auto&& failed = expect(ne(m.error().format().find("MSVC toolset"), std::string::npos)); !failed) failed << m.error().format(); } -} +}}; -TEST(Manifest, TheMsvcSpellingIsAcceptedByTheGrammarElsewhere) { +UTest the_msvc_spelling_is_accepted_by_the_grammar_elsewhere{"Manifest/TheMsvcSpellingIsAcceptedByTheGrammarElsewhere", [] { // On every other row the spelling is a locator the grammar knows; which // row takes it is decided where the target is known // (`mcpp.toolchain.base::refusal`, tests/unit/test_target_base.cpp). auto m = msvc_row("x86_64-windows-gnu", "msvc@14.44.35207"); - ASSERT_TRUE(m.has_value()) << m.error().format(); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); +}}; // ── Dependency version requirements are checked where they are written ─────── // @@ -4691,7 +4628,7 @@ TEST(Manifest, TheMsvcSpellingIsAcceptedByTheGrammarElsewhere) { // `install path missing`. The check added below is a manifest check and has no // business ruling on the installer's path derivation, so this test pins what // the PARSER must keep accepting and says nothing about what fetches. -TEST(Manifest, DependencyVersionParserAcceptsEveryEstablishedForm) { +UTest dependency_version_parser_accepts_every_established_form{"Manifest/DependencyVersionParserAcceptsEveryEstablishedForm", [] { // This test exists to prevent the check from NARROWING anything. for (const char* v : { "0.0.1", "0.0", "^0.0.1", ">=0.0.1, <0.1.0", "*" }) { auto src = std::format(R"( @@ -4702,16 +4639,16 @@ version = "0.1.0" cmdline = "{}" )", v); auto m = mcpp::manifest::parse_string(src); - EXPECT_TRUE(m.has_value()) << v << ": " << (m ? "" : m.error().format()); + if (auto&& failed = expect(m.has_value()); !failed) failed << v << ": " << (m ? "" : m.error().format()); } -} +}}; // REPORTED, NOT REJECTED, and the distinction is load-bearing. The first // version of this check returned an error, and a project pinned to a PUBLISHED // package carrying such a string stopped loading entirely — including when the // offending entry belonged to a feature nobody activates. Published data must // not be invalidated by a new program any more than the reverse. -TEST(Manifest, DependencyVersionReportsATrailingXWithoutFailingTheLoad) { +UTest dependency_version_reports_a_trailing_x_without_failing_the_load{"Manifest/DependencyVersionReportsATrailingXWithoutFailingTheLoad", [] { constexpr auto src = R"( [package] name = "x" @@ -4720,7 +4657,7 @@ version = "0.1.0" cmdline = "0.0.x" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error().format()); // THE ASSERTION IS ON THE PROPERTIES THE MESSAGE MUST CARRY, not on its // prose. Keying on a phrase makes a reworded warning either a silent // no-op or a failure about wording; what a reader needs is the offending @@ -4731,13 +4668,13 @@ cmdline = "0.0.x" for (auto const& w : m->schemaWarnings) { if (w.find("0.0.x") == std::string::npos) continue; found = true; - EXPECT_NE(w.find("PACKAGE"), std::string::npos) << w; - EXPECT_NE(w.find("^1.2.3"), std::string::npos) << w; + expect(ne(w.find("PACKAGE"), std::string::npos)) << w; + expect(ne(w.find("^1.2.3"), std::string::npos)) << w; } - EXPECT_TRUE(found) << "no warning quoting the offending version string"; -} + expect(bool(found)) << "no warning quoting the offending version string"; +}}; -TEST(Manifest, DependencyVersionReportsTrailingGarbage) { +UTest dependency_version_reports_trailing_garbage{"Manifest/DependencyVersionReportsTrailingGarbage", [] { constexpr auto src = R"( [package] name = "x" @@ -4746,19 +4683,19 @@ version = "0.1.0" cmdline = "1.2.3abc" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()); + expect(fatal(m.has_value())); // "some warning was produced" is satisfied by a warning about anything at // all, including a typo elsewhere in the fixture. The warning has to quote // the string it is about, or the reader cannot find it. bool found = false; for (auto const& w : m->schemaWarnings) if (w.find("1.2.3abc") != std::string::npos) found = true; - EXPECT_TRUE(found) << "no warning quoting the offending version string"; -} + expect(bool(found)) << "no warning quoting the offending version string"; +}}; // A path or git dependency has no version, and requiring one would break every // workspace member. -TEST(Manifest, DependencyWithoutAVersionIsUnaffected) { +UTest dependency_without_a_version_is_unaffected{"Manifest/DependencyWithoutAVersionIsUnaffected", [] { constexpr auto src = R"( [package] name = "x" @@ -4767,12 +4704,12 @@ version = "0.1.0" local = { path = "../local" } )"; auto m = mcpp::manifest::parse_string(src); - EXPECT_TRUE(m.has_value()) << (m ? "" : m.error().format()); -} + if (auto&& failed = expect(m.has_value()); !failed) failed << (m ? "" : m.error().format()); +}}; // The long form reaches a different code path than the bare string, and only // one of the two used to set `spec.version`. Both are checked. -TEST(Manifest, LongFormDependencyVersionIsCheckedToo) { +UTest long_form_dependency_version_is_checked_too{"Manifest/LongFormDependencyVersionIsCheckedToo", [] { constexpr auto src = R"( [package] name = "x" @@ -4781,12 +4718,12 @@ version = "0.1.0" cmdline = { version = "0.0.x", features = ["a"] } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error().format()); bool found = false; for (auto const& w : m->schemaWarnings) if (w.find("0.0.x") != std::string::npos) found = true; - EXPECT_TRUE(found) << "the long form's version was not checked"; -} + expect(bool(found)) << "the long form's version was not checked"; +}}; // ─── [hooks] — project build lifecycle commands (#496) ─────────────────── // @@ -4808,50 +4745,48 @@ parse_with_hooks(std::string_view hooksSection) { } } // namespace -TEST(ManifestHooks, AbsentSectionIsInertButNotDisabled) { +UTest absent_section_is_inert_but_not_disabled{"ManifestHooks/AbsentSectionIsInertButNotDisabled", [] { auto m = mcpp::manifest::parse_string(kHookPreamble); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error().format()); // `enabled` defaults to true and says nothing about whether there is work // to do; `active()` is the predicate the fast path is allowed to consult. - EXPECT_TRUE(m->hooks.enabled); - EXPECT_FALSE(m->hooks.active()); - EXPECT_EQ(m->hooks.timeoutSeconds, 10); + expect(bool(m->hooks.enabled)); + expect(!(m->hooks.active())); + expect(eq(m->hooks.timeoutSeconds, 10)); // Experimental: a hook may not decide whether a build succeeded, so this // defaults to false and is the only value the parser accepts. - EXPECT_FALSE(m->hooks.sideEffect); -} + expect(!(m->hooks.sideEffect)); +}}; // The experimental gate. Refused rather than downgraded: honouring it would // give an experimental feature a veto over every build, and ignoring it would // leave the project believing its build is gated on a notifier when nothing // is. Both silent options are worse than the error. -TEST(ManifestHooks, SideEffectTrueIsRefusedWhileTheFeatureIsExperimental) { +UTest side_effect_true_is_refused_while_the_feature_is_experimental{"ManifestHooks/SideEffectTrueIsRefusedWhileTheFeatureIsExperimental", [] { auto m = parse_with_hooks(R"( [hooks] build_finished = "echo done" side_effect = true )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("experimental"), std::string::npos) - << m.error().message; - EXPECT_NE(m.error().message.find("side_effect"), std::string::npos) - << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("experimental"), std::string::npos)); !failed) failed << m.error().message; + if (auto&& failed = expect(ne(m.error().message.find("side_effect"), std::string::npos)); !failed) failed << m.error().message; +}}; // The key stays in the vocabulary: writing the value that IS the current // behaviour must keep working, so a manifest does not have to change when the // feature is promoted. -TEST(ManifestHooks, SideEffectFalseIsAcceptedSoTheKeyStaysReserved) { +UTest side_effect_false_is_accepted_so_the_key_stays_reserved{"ManifestHooks/SideEffectFalseIsAcceptedSoTheKeyStaysReserved", [] { auto m = parse_with_hooks(R"( [hooks] build_finished = "echo done" side_effect = false )"); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); - EXPECT_FALSE(m->hooks.sideEffect); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error().format()); + expect(!(m->hooks.sideEffect)); +}}; -TEST(ManifestHooks, CommandsAndPolicyAreRead) { +UTest commands_and_policy_are_read{"ManifestHooks/CommandsAndPolicyAreRead", [] { auto m = parse_with_hooks(R"( [hooks] build_start = "echo start" @@ -4860,54 +4795,54 @@ build_finished = "echo finished" timeout_seconds = 30 side_effect = false )"); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); - EXPECT_EQ(m->hooks.buildStart.cmd, "echo start"); - EXPECT_EQ(m->hooks.buildFailed.cmd, "echo failed"); - EXPECT_EQ(m->hooks.buildFinished.cmd, "echo finished"); - EXPECT_EQ(m->hooks.timeoutSeconds, 30); - EXPECT_FALSE(m->hooks.sideEffect); - EXPECT_TRUE(m->hooks.active()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error().format()); + expect(eq(m->hooks.buildStart.cmd, "echo start")); + expect(eq(m->hooks.buildFailed.cmd, "echo failed")); + expect(eq(m->hooks.buildFinished.cmd, "echo finished")); + expect(eq(m->hooks.timeoutSeconds, 30)); + expect(!(m->hooks.sideEffect)); + expect(bool(m->hooks.active())); +}}; // `enabled = false` has to switch off a table that still names commands — // otherwise the only way to silence hooks is to delete them. -TEST(ManifestHooks, DisabledTableIsNotActive) { +UTest disabled_table_is_not_active{"ManifestHooks/DisabledTableIsNotActive", [] { auto m = parse_with_hooks(R"( [hooks] build_start = "echo start" enabled = false )"); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); - EXPECT_EQ(m->hooks.buildStart.cmd, "echo start"); - EXPECT_FALSE(m->hooks.active()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error().format()); + expect(eq(m->hooks.buildStart.cmd, "echo start")); + expect(!(m->hooks.active())); +}}; // Policy without a command is not work: a table that only raises the timeout // must leave the fast path alone. -TEST(ManifestHooks, PolicyOnlyTableIsNotActive) { +UTest policy_only_table_is_not_active{"ManifestHooks/PolicyOnlyTableIsNotActive", [] { auto m = parse_with_hooks(R"( [hooks] timeout_seconds = 60 )"); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); - EXPECT_FALSE(m->hooks.active()); - EXPECT_EQ(m->hooks.timeoutSeconds, 60); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error().format()); + expect(!(m->hooks.active())); + expect(eq(m->hooks.timeoutSeconds, 60)); +}}; // Both ends of the documented range are the range: the largest accepted value // is asserted next to the smallest rejected one, so a change to either is a // change to a test. -TEST(ManifestHooks, TheDocumentedTimeoutCeilingIsAccepted) { +UTest the_documented_timeout_ceiling_is_accepted{"ManifestHooks/TheDocumentedTimeoutCeilingIsAccepted", [] { auto m = parse_with_hooks(R"( [hooks] build_start = "echo start" timeout_seconds = 86400 )"); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); - EXPECT_EQ(m->hooks.timeoutSeconds, 86400); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error().format()); + expect(eq(m->hooks.timeoutSeconds, 86400)); +}}; -TEST(ManifestHooks, ValueErrorsAreRejectedWithTheKeyNamed) { +UTest value_errors_are_rejected_with_the_key_named{"ManifestHooks/ValueErrorsAreRejectedWithTheKeyNamed", [] { struct Case { std::string_view section; std::string_view needle; }; for (auto const& c : { Case{R"([hooks] @@ -4928,15 +4863,14 @@ side_effect = 1)", "side_effect"}, timeout_seconds = 86401)", "timeout_seconds"}, }) { auto m = parse_with_hooks(c.section); - ASSERT_FALSE(m.has_value()) << c.section; - EXPECT_NE(m.error().message.find(c.needle), std::string::npos) - << c.section << " -> " << m.error().message; + expect(fatal(!m.has_value())) << c.section; + if (auto&& failed = expect(ne(m.error().message.find(c.needle), std::string::npos)); !failed) failed << c.section << " -> " << m.error().message; } -} +}}; // `hooks = "…"` is the shape a user reaches for when they want one command // and no table. Saying so beats parsing nothing and running nothing. -TEST(ManifestHooks, AScalarHooksKeyIsRejected) { +UTest a_scalar_hooks_key_is_rejected{"ManifestHooks/AScalarHooksKeyIsRejected", [] { auto m = mcpp::manifest::parse_string(R"( hooks = "echo hi" @@ -4944,90 +4878,87 @@ hooks = "echo hi" name = "x" version = "0.1.0" )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("[hooks]"), std::string::npos) - << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("[hooks]"), std::string::npos)); !failed) failed << m.error().message; +}}; // An unknown KEY is a warning, not an error — the same split [build] uses. // A manifest written for a later mcpp (say, a `build_cancelled` event) still // loads on this one, while a typo is still reported. -TEST(ManifestHooks, UnknownKeyWarnsInsteadOfFailing) { +UTest unknown_key_warns_instead_of_failing{"ManifestHooks/UnknownKeyWarnsInsteadOfFailing", [] { auto m = parse_with_hooks(R"( [hooks] build_start = "echo start" build_stared = "echo typo" )"); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error().format()); bool found = false; for (auto const& w : m->schemaWarnings) if (w.find("build_stared") != std::string::npos) found = true; - EXPECT_TRUE(found); - EXPECT_TRUE(m->hooks.active()); -} + expect(bool(found)); + expect(bool(m->hooks.active())); +}}; // ─── The table form, and the interval each event names ─────────────────── -TEST(ManifestHooks, ABareStringIsSugarForATable) { +UTest a_bare_string_is_sugar_for_a_table{"ManifestHooks/ABareStringIsSugarForATable", [] { auto m = parse_with_hooks(R"( [hooks] build_start = "echo start" )"); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); - EXPECT_EQ(m->hooks.buildStart.cmd, "echo start"); - EXPECT_EQ(m->hooks.buildStart.timeoutSeconds, 0); // 0 = inherit - EXPECT_FALSE(m->hooks.buildStart.loop); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error().format()); + expect(eq(m->hooks.buildStart.cmd, "echo start")); + expect(eq(m->hooks.buildStart.timeoutSeconds, 0)); // 0 = inherit + expect(!(m->hooks.buildStart.loop)); +}}; // The per-event value wins over the table's, and 0 is what makes "not given" // expressible — without it there is no way to tell an inherited 10 from a // declared one. -TEST(ManifestHooks, APerEventTimeoutOverridesTheTableDefault) { +UTest a_per_event_timeout_overrides_the_table_default{"ManifestHooks/APerEventTimeoutOverridesTheTableDefault", [] { auto m = parse_with_hooks(R"( [hooks] timeout_seconds = 10 build_finished = { cmd = "slow-notifier", timeout_seconds = 45 } build_start = "echo start" )"); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); - EXPECT_EQ(m->hooks.timeout_for(m->hooks.buildFinished), 45); - EXPECT_EQ(m->hooks.timeout_for(m->hooks.buildStart), 10); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error().format()); + expect(eq(m->hooks.timeout_for(m->hooks.buildFinished), 45)); + expect(eq(m->hooks.timeout_for(m->hooks.buildStart), 10)); +}}; -TEST(ManifestHooks, DuringBuildIsReadAndMakesTheTableActive) { +UTest during_build_is_read_and_makes_the_table_active{"ManifestHooks/DuringBuildIsReadAndMakesTheTableActive", [] { auto m = parse_with_hooks(R"( [hooks] during_build = { cmd = "play bgm.mp3", loop = true } )"); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); - EXPECT_EQ(m->hooks.duringBuild.cmd, "play bgm.mp3"); - EXPECT_TRUE(m->hooks.duringBuild.loop); - EXPECT_TRUE(m->hooks.active()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error().format()); + expect(eq(m->hooks.duringBuild.cmd, "play bgm.mp3")); + expect(bool(m->hooks.duringBuild.loop)); + expect(bool(m->hooks.active())); +}}; // The two keys that only exist for one interval. Accepted-and-ignored is // the failure mode this rejects: the user writes `loop` on `build_start`, // nothing repeats, and the feature looks broken rather than misused. Each // message names the event that does support the key. -TEST(ManifestHooks, AKeyOfferedToTheWrongIntervalIsRejected) { +UTest a_key_offered_to_the_wrong_interval_is_rejected{"ManifestHooks/AKeyOfferedToTheWrongIntervalIsRejected", [] { auto loopOnSelfClosing = parse_with_hooks(R"( [hooks] build_start = { cmd = "play bgm.mp3", loop = true } )"); - ASSERT_FALSE(loopOnSelfClosing.has_value()); - EXPECT_NE(loopOnSelfClosing.error().message.find("during_build"), - std::string::npos) << loopOnSelfClosing.error().message; + expect(fatal(!loopOnSelfClosing.has_value())); + if (auto&& failed = expect(ne(loopOnSelfClosing.error().message.find("during_build"), std::string::npos)); !failed) failed << loopOnSelfClosing.error().message; auto timeoutOnSpanning = parse_with_hooks(R"( [hooks] during_build = { cmd = "play bgm.mp3", timeout_seconds = 30 } )"); - ASSERT_FALSE(timeoutOnSpanning.has_value()); - EXPECT_NE(timeoutOnSpanning.error().message.find("timeout_seconds"), - std::string::npos) << timeoutOnSpanning.error().message; -} + expect(fatal(!timeoutOnSpanning.has_value())); + if (auto&& failed = expect(ne(timeoutOnSpanning.error().message.find("timeout_seconds"), std::string::npos)); !failed) failed << timeoutOnSpanning.error().message; +}}; -TEST(ManifestHooks, ATableWithoutCmdIsRejected) { +UTest a_table_without_cmd_is_rejected{"ManifestHooks/ATableWithoutCmdIsRejected", [] { for (std::string_view section : { R"([hooks] during_build = { loop = true })", @@ -5037,24 +4968,23 @@ build_start = { cmd = "" })", build_start = 42)", }) { auto m = parse_with_hooks(section); - ASSERT_FALSE(m.has_value()) << section; - EXPECT_NE(m.error().message.find("cmd"), std::string::npos) - << section << " -> " << m.error().message; + expect(fatal(!m.has_value())) << section; + if (auto&& failed = expect(ne(m.error().message.find("cmd"), std::string::npos)); !failed) failed << section << " -> " << m.error().message; } -} +}}; -TEST(ManifestHooks, AnUnknownKeyInsideAnEventTableWarns) { +UTest an_unknown_key_inside_an_event_table_warns{"ManifestHooks/AnUnknownKeyInsideAnEventTableWarns", [] { auto m = parse_with_hooks(R"( [hooks] during_build = { cmd = "play bgm.mp3", lopo = true } )"); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error().format()); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error().format()); bool found = false; for (auto const& w : m->schemaWarnings) if (w.find("lopo") != std::string::npos) found = true; - EXPECT_TRUE(found); - EXPECT_TRUE(m->hooks.active()); -} + expect(bool(found)); + expect(bool(m->hooks.active())); +}}; // ─── #544: array keys in the [target.] sweep; [xlings] per host ──── @@ -5063,7 +4993,7 @@ during_build = { cmd = "play bgm.mp3", lopo = true } // silence and left `runner` out of the "supported keys" list. Both halves of // the issue's third ask are pinned here; TargetRunnerParsesAndDoesNotWarn // above keeps the other direction (the real key stays unreported). -TEST(Manifest, TargetSweepReportsArrayTyposAndListsRunner) { +UTest target_sweep_reports_array_typos_and_lists_runner{"Manifest/TargetSweepReportsArrayTyposAndListsRunner", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5071,23 +5001,22 @@ version = "0.1.0" [target.aarch64-linux-musl] runnerX = ["qemu-aarch64-static"] )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->schemaWarnings.size(), 1u); - EXPECT_NE(m->schemaWarnings[0].find("'runnerX'"), std::string::npos) << m->schemaWarnings[0]; + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->schemaWarnings.size(), 1u))); + if (auto&& failed = expect(ne(m->schemaWarnings[0].find("'runnerX'"), std::string::npos)); !failed) failed << m->schemaWarnings[0]; // #610 / #630 item 5 — `min_api_level` joined this list; see // tests/unit/test_target_scalar_keys.cpp for the test that keeps this // string (and the array beside it) from drifting from the parser again. - EXPECT_NE(m->schemaWarnings[0].find( + expect(ne(m->schemaWarnings[0].find( "Supported keys: cxx_runtime, linkage, min_api_level, platform, run_under, " - "runner, sysroot, toolchain"), - std::string::npos) << m->schemaWarnings[0]; -} + "runner, sysroot, toolchain"), std::string::npos)) << m->schemaWarnings[0]; +}}; // `[xlings]` mirrors `.xlings.json`, whose values may be an object keyed by // platform. The parser resolves the form against this host at load, so the // assertions below are written in terms of host_platform_key() rather than a // fixed platform: the same test file runs on all three CI hosts. -TEST(Manifest, XlingsDepsAcceptPerPlatformEntries) { +UTest xlings_deps_accept_per_platform_entries{"Manifest/XlingsDepsAcceptPerPlatformEntries", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5095,15 +5024,15 @@ version = "0.1.0" [xlings] deps = ["xim:ninja", { linux = "qemu-user-aarch64" }, { windows = "nasm", default = "yasm" }] )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); const auto host = mcpp::manifest::host_platform_key(); std::vector want{"xim:ninja"}; if (host == "linux") want.push_back("qemu-user-aarch64"); want.push_back(host == "windows" ? "nasm" : "yasm"); - EXPECT_EQ(m->xlings.deps, want); -} + expect(eq(m->xlings.deps, want)); +}}; -TEST(Manifest, XlingsWorkspaceAcceptsPerPlatformValues) { +UTest xlings_workspace_accepts_per_platform_values{"Manifest/XlingsWorkspaceAcceptsPerPlatformValues", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5113,18 +5042,18 @@ gcc = { linux = "15.1.0" } llvm = { macos = "20", default = "22" } xmake = "3.0.7" )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); const auto host = mcpp::manifest::host_platform_key(); - if (host == "linux") EXPECT_EQ(m->xlings.workspace.at("gcc"), "15.1.0"); - else EXPECT_EQ(m->xlings.workspace.count("gcc"), 0u); + if (host == "linux") expect(eq(m->xlings.workspace.at("gcc"), "15.1.0")); + else expect(eq(m->xlings.workspace.count("gcc"), 0u)); // `host_platform_key()` is xlings' spelling, `macosx`, since 2026.9.3.1. // Comparing against "macos" here made this the one assertion in the file // that could only fail on a macOS runner — and it did. - EXPECT_EQ(m->xlings.workspace.at("llvm"), host == "macosx" ? "20" : "22"); - EXPECT_EQ(m->xlings.workspace.at("xmake"), "3.0.7"); -} + expect(eq(m->xlings.workspace.at("llvm"), host == "macosx" ? "20" : "22")); + expect(eq(m->xlings.workspace.at("xmake"), "3.0.7")); +}}; -TEST(Manifest, XlingsUnknownPlatformKeyIsAnError) { +UTest xlings_unknown_platform_key_is_an_error{"Manifest/XlingsUnknownPlatformKeyIsAnError", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5132,15 +5061,13 @@ version = "0.1.0" [xlings] deps = [{ linxu = "qemu-user-aarch64" }] )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("linxu"), std::string::npos) << m.error().message; - EXPECT_NE(m.error().message.find("linux, macosx (or macos), windows, default"), - std::string::npos) - << m.error().message; - EXPECT_NE(m.error().message.find("deps[0]"), std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("linxu"), std::string::npos)); !failed) failed << m.error().message; + if (auto&& failed = expect(ne(m.error().message.find("linux, macosx (or macos), windows, default"), std::string::npos)); !failed) failed << m.error().message; + if (auto&& failed = expect(ne(m.error().message.find("deps[0]"), std::string::npos)); !failed) failed << m.error().message; +}}; -TEST(Manifest, XlingsWorkspaceNonStringLeafIsAnError) { +UTest xlings_workspace_non_string_leaf_is_an_error{"Manifest/XlingsWorkspaceNonStringLeafIsAnError", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5148,28 +5075,27 @@ version = "0.1.0" [xlings.workspace] gcc = { linux = 15 } )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("[xlings.workspace] gcc"), std::string::npos) - << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("[xlings.workspace] gcc"), std::string::npos)); !failed) failed << m.error().message; +}}; // The resolution rule itself, with the platform passed explicitly so all // three hosts' answers are asserted on every host. -TEST(Manifest, ResolveHostValueTable) { +UTest resolve_host_value_table{"Manifest/ResolveHostValueTable", [] { using mcpp::manifest::resolve_host_value; auto d = mcpp::libs::toml::parse(R"(v = { linux = "a", macosx = "b", default = "c" })"); - ASSERT_TRUE(d.has_value()); + expect(fatal(d.has_value())); const auto& v = d->root().at("v"); - EXPECT_EQ(resolve_host_value(v, "linux").value().value(), "a"); - EXPECT_EQ(resolve_host_value(v, "macos").value().value(), "b"); // macosx alias - EXPECT_EQ(resolve_host_value(v, "windows").value().value(), "c"); + expect(eq(resolve_host_value(v, "linux").value().value(), "a")); + expect(eq(resolve_host_value(v, "macos").value().value(), "b")); // macosx alias + expect(eq(resolve_host_value(v, "windows").value().value(), "c")); auto e = mcpp::libs::toml::parse(R"(v = { linux = "a" })"); - ASSERT_TRUE(e.has_value()); - EXPECT_FALSE(resolve_host_value(e->root().at("v"), "windows").value().has_value()); + expect(fatal(e.has_value())); + expect(!(resolve_host_value(e->root().at("v"), "windows").value().has_value())); auto s = mcpp::libs::toml::parse(R"(v = "plain")"); - ASSERT_TRUE(s.has_value()); - EXPECT_EQ(resolve_host_value(s->root().at("v"), "windows").value().value(), "plain"); -} + expect(fatal(s.has_value())); + expect(eq(resolve_host_value(s->root().at("v"), "windows").value().value(), "plain")); +}}; // ── `[xlings.workspace]` as the one table (2026-09-03) ───────────────────── // @@ -5178,7 +5104,7 @@ TEST(Manifest, ResolveHostValueTable) { // pin — and the assertions are on both, because a test that checked only one // could not tell a dropped namespace from a dropped version. -TEST(Manifest, XlingsWorkspaceProducesAnAddressAndAPin) { +UTest xlings_workspace_produces_an_address_and_a_pin{"Manifest/XlingsWorkspaceProducesAnAddressAndAPin", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5186,13 +5112,13 @@ version = "0.1.0" [xlings.workspace] cmake = "3.28" )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->xlings.workspace.at("cmake"), "3.28"); - ASSERT_EQ(m->xlings.deps.size(), 1u); - EXPECT_EQ(m->xlings.deps[0], "cmake@3.28"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->xlings.workspace.at("cmake"), "3.28")); + expect(fatal(eq(m->xlings.deps.size(), 1u))); + expect(eq(m->xlings.deps[0], "cmake@3.28")); +}}; -TEST(Manifest, XlingsWorkspaceNamespaceOnEitherHalfIsTheSameEntry) { +UTest xlings_workspace_namespace_on_either_half_is_the_same_entry{"Manifest/XlingsWorkspaceNamespaceOnEitherHalfIsTheSameEntry", [] { auto onValue = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5200,7 +5126,7 @@ version = "0.1.0" [xlings.workspace] picolibc-riscv = "xim:1.8.12" )"); - ASSERT_TRUE(onValue.has_value()) << onValue.error().format(); + if (auto&& failed = expect(fatal(onValue.has_value())); !failed) failed << onValue.error().format(); auto onKey = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5208,14 +5134,14 @@ version = "0.1.0" [xlings.workspace] "xim:picolibc-riscv" = "1.8.12" )"); - ASSERT_TRUE(onKey.has_value()) << onKey.error().format(); - EXPECT_EQ(onValue->xlings.deps, onKey->xlings.deps); - EXPECT_EQ(onValue->xlings.workspace, onKey->xlings.workspace); - EXPECT_EQ(onValue->xlings.deps.at(0), "xim:picolibc-riscv@1.8.12"); - EXPECT_EQ(onValue->xlings.workspace.at("picolibc-riscv"), "xim:1.8.12"); -} - -TEST(Manifest, XlingsWorkspaceDisagreeingNamespacesAreRefused) { + if (auto&& failed = expect(fatal(onKey.has_value())); !failed) failed << onKey.error().format(); + expect(eq(onValue->xlings.deps, onKey->xlings.deps)); + expect(eq(onValue->xlings.workspace, onKey->xlings.workspace)); + expect(eq(onValue->xlings.deps.at(0), "xim:picolibc-riscv@1.8.12")); + expect(eq(onValue->xlings.workspace.at("picolibc-riscv"), "xim:1.8.12")); +}}; + +UTest xlings_workspace_disagreeing_namespaces_are_refused{"Manifest/XlingsWorkspaceDisagreeingNamespacesAreRefused", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5223,12 +5149,12 @@ version = "0.1.0" [xlings.workspace] "xim:foo" = "other:1.0" )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("xim"), std::string::npos) << m.error().message; - EXPECT_NE(m.error().message.find("other"), std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("xim"), std::string::npos)); !failed) failed << m.error().message; + if (auto&& failed = expect(ne(m.error().message.find("other"), std::string::npos)); !failed) failed << m.error().message; +}}; -TEST(Manifest, XlingsWorkspaceNamingOnePackageTwiceIsRefused) { +UTest xlings_workspace_naming_one_package_twice_is_refused{"Manifest/XlingsWorkspaceNamingOnePackageTwiceIsRefused", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5237,15 +5163,15 @@ version = "0.1.0" foo = "1.0" "xim:foo" = "2.0" )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("twice"), std::string::npos) << m.error().message; - EXPECT_NE(m.error().message.find("xim:foo"), std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("twice"), std::string::npos)); !failed) failed << m.error().message; + if (auto&& failed = expect(ne(m.error().message.find("xim:foo"), std::string::npos)); !failed) failed << m.error().message; +}}; // `""` is the authored spelling for "present, version unconstrained"; it is // what `d2mcpp/.xlings.json` already writes. Both halves are asserted: an // address with no `@`, and no pin at all. -TEST(Manifest, XlingsWorkspaceEmptyVersionAsksOnlyForPresence) { +UTest xlings_workspace_empty_version_asks_only_for_presence{"Manifest/XlingsWorkspaceEmptyVersionAsksOnlyForPresence", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5253,14 +5179,14 @@ version = "0.1.0" [xlings.workspace] code = "" )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->xlings.deps.at(0), "code"); - EXPECT_TRUE(m->xlings.workspace.at("code").empty()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->xlings.deps.at(0), "code")); + expect(m->xlings.workspace.at("code").empty()); +}}; // The descriptor emitter needs every platform, and the host resolution has // already discarded two of them — so the unresolved declaration is kept. -TEST(Manifest, XlingsWorkspaceKeepsEveryPlatformForTheEmitter) { +UTest xlings_workspace_keeps_every_platform_for_the_emitter{"Manifest/XlingsWorkspaceKeepsEveryPlatformForTheEmitter", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5269,26 +5195,22 @@ version = "0.1.0" qemu = { linux = "9.2.4-1", windows = "9.2.4-2" } ninja = "1.12.1" )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); const auto& byPlat = m->xlings.workspaceByPlatform; - ASSERT_TRUE(byPlat.contains("linux")); - ASSERT_TRUE(byPlat.contains("windows")); - EXPECT_NE(std::ranges::find(byPlat.at("linux"), "qemu@9.2.4-1"), - byPlat.at("linux").end()); - EXPECT_NE(std::ranges::find(byPlat.at("windows"), "qemu@9.2.4-2"), - byPlat.at("windows").end()); + expect(fatal(byPlat.contains("linux"))); + expect(fatal(byPlat.contains("windows"))); + expect(ne(std::ranges::find(byPlat.at("linux"), "qemu@9.2.4-1"), byPlat.at("linux").end())); + expect(ne(std::ranges::find(byPlat.at("windows"), "qemu@9.2.4-2"), byPlat.at("windows").end())); // A plain string speaks for every platform, macosx included, even though // `qemu` named none there. - ASSERT_TRUE(byPlat.contains("macosx")); - EXPECT_NE(std::ranges::find(byPlat.at("macosx"), "ninja@1.12.1"), - byPlat.at("macosx").end()); - EXPECT_EQ(std::ranges::find(byPlat.at("macosx"), "qemu@9.2.4-1"), - byPlat.at("macosx").end()); -} + expect(fatal(byPlat.contains("macosx"))); + expect(ne(std::ranges::find(byPlat.at("macosx"), "ninja@1.12.1"), byPlat.at("macosx").end())); + expect(eq(std::ranges::find(byPlat.at("macosx"), "qemu@9.2.4-1"), byPlat.at("macosx").end())); +}}; // `deps` is honoured and reported. Refusing it would reach a DEPENDENCY's // manifest, which a consumer that pinned that package cannot edit. -TEST(Manifest, XlingsDepsIsHonouredAndReported) { +UTest xlings_deps_is_honoured_and_reported{"Manifest/XlingsDepsIsHonouredAndReported", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5296,22 +5218,20 @@ version = "0.1.0" [xlings] deps = ["xim:qemu-riscv@9.2.4-1"] )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->xlings.deps.at(0), "xim:qemu-riscv@9.2.4-1"); - ASSERT_EQ(m->schemaWarnings.size(), 1u); - EXPECT_NE(m->schemaWarnings[0].find("[xlings.workspace]"), std::string::npos) - << m->schemaWarnings[0]; + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->xlings.deps.at(0), "xim:qemu-riscv@9.2.4-1")); + expect(fatal(eq(m->schemaWarnings.size(), 1u))); + if (auto&& failed = expect(ne(m->schemaWarnings[0].find("[xlings.workspace]"), std::string::npos)); !failed) failed << m->schemaWarnings[0]; // The message shows the line to write, in the RECOMMENDED spelling: the // namespace on the key, which is what an author writes and what every // official package uses. - EXPECT_NE(m->schemaWarnings[0].find("\"xim:qemu-riscv\" = \"9.2.4-1\""), - std::string::npos) << m->schemaWarnings[0]; -} + expect(ne(m->schemaWarnings[0].find("\"xim:qemu-riscv\" = \"9.2.4-1\""), std::string::npos)) << m->schemaWarnings[0]; +}}; // `envs` was materialised and read by nothing, while the documentation said it // reached the tool environment. A key that does nothing is worse when // something claims it does, so this is an error rather than a warning. -TEST(Manifest, XlingsEnvsIsRefused) { +UTest xlings_envs_is_refused{"Manifest/XlingsEnvsIsRefused", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5319,16 +5239,15 @@ version = "0.1.0" [xlings.envs] OPENBLAS_NUM_THREADS = "1" )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("[xlings.envs]"), std::string::npos) - << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("[xlings.envs]"), std::string::npos)); !failed) failed << m.error().message; +}}; // `macos` and `macosx` are one platform under two vocabularies — mcpp's triple // says the first, a descriptor and xlings' project file say the second. The // rule lives in mcpp.toolchain.platform; this asserts the manifest reaches it, // on every host, by resolving explicitly rather than against this one. -TEST(Manifest, XlingsWorkspaceAcceptsBothMacosSpellings) { +UTest xlings_workspace_accepts_both_macos_spellings{"Manifest/XlingsWorkspaceAcceptsBothMacosSpellings", [] { auto with_macos = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5336,7 +5255,7 @@ version = "0.1.0" [xlings.workspace] llvm = { macos = "20", default = "22" } )"); - ASSERT_TRUE(with_macos.has_value()) << with_macos.error().format(); + if (auto&& failed = expect(fatal(with_macos.has_value())); !failed) failed << with_macos.error().format(); auto with_macosx = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5344,19 +5263,16 @@ version = "0.1.0" [xlings.workspace] llvm = { macosx = "20", default = "22" } )"); - ASSERT_TRUE(with_macosx.has_value()) << with_macosx.error().format(); + if (auto&& failed = expect(fatal(with_macosx.has_value())); !failed) failed << with_macosx.error().format(); // Same platform, so the same per-platform declaration and the same // host-resolved answer, whichever way it was written. - EXPECT_EQ(with_macos->xlings.workspaceByPlatform, - with_macosx->xlings.workspaceByPlatform); - EXPECT_EQ(with_macos->xlings.workspace, with_macosx->xlings.workspace); - EXPECT_NE(std::ranges::find(with_macos->xlings.workspaceByPlatform.at("macosx"), - "llvm@20"), - with_macos->xlings.workspaceByPlatform.at("macosx").end()); - EXPECT_NE(std::ranges::find(with_macos->xlings.workspaceByPlatform.at("linux"), - "llvm@22"), - with_macos->xlings.workspaceByPlatform.at("linux").end()); -} + expect(eq(with_macos->xlings.workspaceByPlatform, with_macosx->xlings.workspaceByPlatform)); + expect(eq(with_macos->xlings.workspace, with_macosx->xlings.workspace)); + expect(ne(std::ranges::find(with_macos->xlings.workspaceByPlatform.at("macosx"), + "llvm@20"), with_macos->xlings.workspaceByPlatform.at("macosx").end())); + expect(ne(std::ranges::find(with_macos->xlings.workspaceByPlatform.at("linux"), + "llvm@22"), with_macos->xlings.workspaceByPlatform.at("linux").end())); +}}; // ─── Tool tiers: `when` on an entry, and `[feature-xlings.]` ──────────── // @@ -5365,7 +5281,7 @@ llvm = { macosx = "20", default = "22" } // keyed by `version` and `when`. Neither key set contains a member of the // other, so the presence of `version`/`when` decides — and this test is what // says so, because nothing in the parser's shape forces it. -TEST(Manifest, XlingsWorkspaceEntryTiers) { +UTest xlings_workspace_entry_tiers{"Manifest/XlingsWorkspaceEntryTiers", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5377,24 +5293,24 @@ ran = { version = "3.0", when = "run" } devd = { version = "4.0", when = "dev" } anyver = { version = "", when = "run" } )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); using W = mcpp::manifest::ToolWhen; // An entry with no `when` is on the tier every verb installs. That is the // pre-2026.9.4.2 behaviour, and it is the reason no manifest has to change. - EXPECT_EQ(m->xlings.when_of("plain@1.0"), W::Always); - EXPECT_EQ(m->xlings.when_of("built@2.0"), W::Build); - EXPECT_EQ(m->xlings.when_of("ran@3.0"), W::Run); - EXPECT_EQ(m->xlings.when_of("devd@4.0"), W::Dev); + expect(eq(m->xlings.when_of("plain@1.0"), W::Always)); + expect(eq(m->xlings.when_of("built@2.0"), W::Build)); + expect(eq(m->xlings.when_of("ran@3.0"), W::Run)); + expect(eq(m->xlings.when_of("devd@4.0"), W::Dev)); // A tier does not change the address, only when it is asked for. An entry // that names no version asks for presence, exactly as the plain spelling // does — the address carries no `@`. - EXPECT_EQ(m->xlings.when_of("anyver"), W::Run); - EXPECT_EQ(m->xlings.workspace.at("plain"), "1.0"); - EXPECT_EQ(m->xlings.workspace.at("ran"), "3.0"); - EXPECT_TRUE(m->xlings.workspace.at("anyver").empty()); -} + expect(eq(m->xlings.when_of("anyver"), W::Run)); + expect(eq(m->xlings.workspace.at("plain"), "1.0")); + expect(eq(m->xlings.workspace.at("ran"), "3.0")); + expect(m->xlings.workspace.at("anyver").empty()); +}}; -TEST(Manifest, XlingsWorkspacePlatformTableStillParses) { +UTest xlings_workspace_platform_table_still_parses{"Manifest/XlingsWorkspacePlatformTableStillParses", [] { // The regression this pair guards: adding the tier form must not make a // platform table read as a malformed tier table. auto m = mcpp::manifest::parse_string(R"( @@ -5404,11 +5320,11 @@ version = "0.1.0" [xlings.workspace] llvm = { macos = "20", default = "22" } )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->xlings.when_of(m->xlings.deps.at(0)), mcpp::manifest::ToolWhen::Always); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->xlings.when_of(m->xlings.deps.at(0)), mcpp::manifest::ToolWhen::Always)); +}}; -TEST(Manifest, XlingsWorkspaceBadTierIsAnError) { +UTest xlings_workspace_bad_tier_is_an_error{"Manifest/XlingsWorkspaceBadTierIsAnError", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5416,14 +5332,12 @@ version = "0.1.0" [xlings.workspace] tool = { version = "1.0", when = "sometimes" } )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("sometimes"), std::string::npos) - << m.error().message; - EXPECT_NE(m.error().message.find("'build', 'run' or 'dev'"), std::string::npos) - << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("sometimes"), std::string::npos)); !failed) failed << m.error().message; + if (auto&& failed = expect(ne(m.error().message.find("'build', 'run' or 'dev'"), std::string::npos)); !failed) failed << m.error().message; +}}; -TEST(Manifest, XlingsWorkspaceScopedEntryNeedsVersion) { +UTest xlings_workspace_scoped_entry_needs_version{"Manifest/XlingsWorkspaceScopedEntryNeedsVersion", [] { // `{ when = "run" }` and a typo of `version` are indistinguishable, so the // key is required and `""` is how "any version" is said. auto m = mcpp::manifest::parse_string(R"( @@ -5433,12 +5347,11 @@ version = "0.1.0" [xlings.workspace] tool = { versoin = "1.0", when = "run" } )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("versoin"), std::string::npos) - << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("versoin"), std::string::npos)); !failed) failed << m.error().message; +}}; -TEST(Manifest, FeatureXlingsIsGatedAndReported) { +UTest feature_xlings_is_gated_and_reported{"Manifest/FeatureXlingsIsGatedAndReported", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "app" @@ -5452,21 +5365,20 @@ hardware = {} [feature-xlings.hardwear] "xim:typo" = "1.0" )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->xlings.featureDeps.count("hardware"), 1u); - EXPECT_EQ(m->xlings.featureDeps.at("hardware").at(0), "xim:probe-rs@0.24.0"); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->xlings.featureDeps.count("hardware"), 1u))); + expect(eq(m->xlings.featureDeps.at("hardware").at(0), "xim:probe-rs@0.24.0")); // A feature-gated tool is NOT in the unconditional list; a consumer that // never activates the feature never asks for it. - EXPECT_EQ(std::ranges::find(m->xlings.deps, "xim:probe-rs@0.24.0"), - m->xlings.deps.end()); + expect(eq(std::ranges::find(m->xlings.deps, "xim:probe-rs@0.24.0"), m->xlings.deps.end())); // A misspelt feature installs nothing and says nothing, which is the // shape #531 was filed for. Reported as a schema warning rather than an // error, so a package may adopt a feature before its consumers upgrade. bool sawTypo = false; for (auto const& w : m->schemaWarnings) if (w.find("hardwear") != std::string::npos) sawTypo = true; - EXPECT_TRUE(sawTypo) << "no warning for [feature-xlings.hardwear]"; -} + if (auto&& failed = expect(bool(sawTypo)); !failed) failed << "no warning for [feature-xlings.hardwear]"; +}}; // ── [package] exclusive ──────────────────────────────────────────────────── // @@ -5482,7 +5394,7 @@ hardware = {} // plain list (every existing reader keeps working); the constraint is what // prepare narrows on. -TEST(ManifestSourceConstraints, ATableEntryJoinsTheListAndCarriesItsConstraint) { +UTest a_table_entry_joins_the_list_and_carries_its_constraint{"ManifestSourceConstraints/ATableEntryJoinsTheListAndCarriesItsConstraint", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "infer" @@ -5490,17 +5402,17 @@ version = "0.1.0" [build] sources = ["src/**/*.cppm", { glob = "src/kernels/**/*.cu", accel = "cuda12.9+{sm_89}" }] )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->buildConfig.sources.size(), 2u); - EXPECT_EQ(m->buildConfig.sources[0], "src/**/*.cppm"); - EXPECT_EQ(m->buildConfig.sources[1], "src/kernels/**/*.cu"); - EXPECT_TRUE(m->buildConfig.sourcesDeclared); - ASSERT_EQ(m->buildConfig.sourceConstraints.size(), 1u); - EXPECT_EQ(m->buildConfig.sourceConstraints[0].glob, "src/kernels/**/*.cu"); - EXPECT_EQ(m->buildConfig.sourceConstraints[0].accel, "cuda12.9+{sm_89}"); -} - -TEST(ManifestSourceConstraints, ATableEntryWithoutAccelIsJustAGlob) { + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->buildConfig.sources.size(), 2u))); + expect(eq(m->buildConfig.sources[0], "src/**/*.cppm")); + expect(eq(m->buildConfig.sources[1], "src/kernels/**/*.cu")); + expect(bool(m->buildConfig.sourcesDeclared)); + expect(fatal(eq(m->buildConfig.sourceConstraints.size(), 1u))); + expect(eq(m->buildConfig.sourceConstraints[0].glob, "src/kernels/**/*.cu")); + expect(eq(m->buildConfig.sourceConstraints[0].accel, "cuda12.9+{sm_89}")); +}}; + +UTest a_table_entry_without_accel_is_just_a_glob{"ManifestSourceConstraints/ATableEntryWithoutAccelIsJustAGlob", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "infer" @@ -5508,12 +5420,12 @@ version = "0.1.0" [build] sources = [{ glob = "src/**/*.cpp" }] )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->buildConfig.sources.size(), 1u); - EXPECT_TRUE(m->buildConfig.sourceConstraints.empty()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->buildConfig.sources.size(), 1u))); + expect(m->buildConfig.sourceConstraints.empty()); +}}; -TEST(ManifestSourceConstraints, AnEntryWithoutGlobIsRefused) { +UTest an_entry_without_glob_is_refused{"ManifestSourceConstraints/AnEntryWithoutGlobIsRefused", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "infer" @@ -5521,11 +5433,11 @@ version = "0.1.0" [build] sources = [{ accel = "cuda12.9+{sm_89}" }] )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().format().find("glob"), std::string::npos) << m.error().format(); -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().format().find("glob"), std::string::npos)); !failed) failed << m.error().format(); +}}; -TEST(ManifestSourceConstraints, AnUnknownKeyIsRefusedRatherThanSkipped) { +UTest an_unknown_key_is_refused_rather_than_skipped{"ManifestSourceConstraints/AnUnknownKeyIsRefusedRatherThanSkipped", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "infer" @@ -5533,11 +5445,11 @@ version = "0.1.0" [build] sources = [{ glob = "src/k/*.cu", acel = "cuda12.9+{sm_89}" }] )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().format().find("acel"), std::string::npos) << m.error().format(); -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().format().find("acel"), std::string::npos)); !failed) failed << m.error().format(); +}}; -TEST(ManifestSourceConstraints, AnExclusionCannotBeConstrained) { +UTest an_exclusion_cannot_be_constrained{"ManifestSourceConstraints/AnExclusionCannotBeConstrained", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "infer" @@ -5545,10 +5457,10 @@ version = "0.1.0" [build] sources = [{ glob = "!src/k/*.cu", accel = "cuda12.9+{sm_89}" }] )"); - ASSERT_FALSE(m.has_value()); -} + expect(fatal(!m.has_value())); +}}; -TEST(ManifestSourceConstraints, AConstrainedEntryUnderTheConditionalAxisIsRefused) { +UTest a_constrained_entry_under_the_conditional_axis_is_refused{"ManifestSourceConstraints/AConstrainedEntryUnderTheConditionalAxisIsRefused", [] { // read_list keeps strings only; a table that vanished there would be a // device glob nobody ever narrowed, so it is refused with a pointer to // where it belongs. @@ -5559,11 +5471,11 @@ version = "0.1.0" [target.'cfg(linux)'.build] sources = [{ glob = "src/k/*.cu", accel = "cuda12.9+{sm_89}" }] )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().format().find("[build].sources"), std::string::npos) << m.error().format(); -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().format().find("[build].sources"), std::string::npos)); !failed) failed << m.error().format(); +}}; -TEST(ManifestExclusive, CarriesTheListAndWarnsAboutNothing) { +UTest carries_the_list_and_warns_about_nothing{"ManifestExclusive/CarriesTheListAndWarnsAboutNothing", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "compat.cublas" @@ -5571,15 +5483,15 @@ version = "0.1.0" provides = ["gpu-blas", "gpu-fft"] exclusive = ["gpu-blas"] )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->exclusive.size(), 1u); - EXPECT_EQ(m->exclusive[0], "gpu-blas"); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->exclusive.size(), 1u))); + expect(eq(m->exclusive[0], "gpu-blas")); std::string all; for (auto const& w : m->schemaWarnings) { all += w; all += '\n'; } - EXPECT_TRUE(m->schemaWarnings.empty()) << all; -} + expect(m->schemaWarnings.empty()) << all; +}}; -TEST(ManifestExclusive, ClaimingSomethingNotProvidedIsReported) { +UTest claiming_something_not_provided_is_reported{"ManifestExclusive/ClaimingSomethingNotProvidedIsReported", [] { // Not an error: the declaration is carried so the binding-time check sees // exactly what was written. What it cannot do is act on a capability this // package does not supply, and a silent no-op is how a typo survives. @@ -5590,15 +5502,15 @@ version = "0.1.0" provides = ["gpu-blas"] exclusive = ["gpu-bals"] )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->exclusive.size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->exclusive.size(), 1u))); std::string all; for (auto const& w : m->schemaWarnings) { all += w; all += '\n'; } - EXPECT_NE(all.find("gpu-bals"), std::string::npos) + expect(ne(all.find("gpu-bals"), std::string::npos)) << "the warning must name the entry that cannot be enforced:\n" << all; -} +}}; -TEST(ManifestExclusive, AFeatureProvidedCapabilityCounts) { +UTest a_feature_provided_capability_counts{"ManifestExclusive/AFeatureProvidedCapabilityCounts", [] { // `provides` is not the only way a package supplies a capability, and a // check that only looked there would warn about a correct manifest. auto m = mcpp::manifest::parse_string(R"( @@ -5610,12 +5522,12 @@ exclusive = ["gpu-blas"] [features] cuda = { provides = ["gpu-blas"] } )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); std::string all; for (auto const& w : m->schemaWarnings) { all += w; all += '\n'; } - EXPECT_EQ(all.find("gpu-blas"), std::string::npos) + expect(eq(all.find("gpu-blas"), std::string::npos)) << "a feature-provided capability is supplied; no warning is due:\n" << all; -} +}}; // A predicate that carries ONLY a `[target..runtime]` table is recorded. // @@ -5629,7 +5541,7 @@ cuda = { provides = ["gpu-blas"] } // `frameworks` is the third per-target runtime key (#622 A2): a table that // carries only it is recorded, it is not reported as unsupported, and a key // outside the three is still reported naming all three. -TEST(Manifest, AConditionalFrameworksTableIsRecordedAndNotWarned) { +UTest a_conditional_frameworks_table_is_recorded_and_not_warned{"Manifest/AConditionalFrameworksTableIsRecordedAndNotWarned", [] { constexpr auto only_frameworks = R"( [package] name = "x" @@ -5638,13 +5550,13 @@ version = "0.1.0" frameworks = ["UIKit", "MobileCoreServices"] )"; auto m = mcpp::manifest::parse_string(only_frameworks); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->conditionalConfigs.size(), 1u) + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->conditionalConfigs.size(), 1u))) << "a frameworks-only predicate was parsed and then discarded"; - ASSERT_EQ(m->conditionalConfigs[0].frameworks.size(), 2u); - EXPECT_EQ(m->conditionalConfigs[0].frameworks[0], "UIKit"); + expect(fatal(eq(m->conditionalConfigs[0].frameworks.size(), 2u))); + expect(eq(m->conditionalConfigs[0].frameworks[0], "UIKit")); for (auto const& w : m->schemaWarnings) - EXPECT_EQ(w.find("frameworks"), std::string::npos) << w; + expect(eq(w.find("frameworks"), std::string::npos)) << w; constexpr auto a_fourth_key = R"( [package] @@ -5654,16 +5566,16 @@ version = "0.1.0" deploy_files = ["x"] )"; auto m2 = mcpp::manifest::parse_string(a_fourth_key); - ASSERT_TRUE(m2.has_value()) << m2.error().format(); + if (auto&& failed = expect(fatal(m2.has_value())); !failed) failed << m2.error().format(); bool named = false; for (auto const& w : m2->schemaWarnings) if (w.find("unsupported key 'deploy_files'") != std::string::npos && w.find("frameworks, libraries, link_library_dirs") != std::string::npos) named = true; - EXPECT_TRUE(named) << "the unsupported-key report must list the three keys"; -} + expect(bool(named)) << "the unsupported-key report must list the three keys"; +}}; -TEST(Manifest, AConditionalRuntimeTableAloneIsRecorded) { +UTest a_conditional_runtime_table_alone_is_recorded{"Manifest/AConditionalRuntimeTableAloneIsRecorded", [] { constexpr auto only_runtime = R"( [package] name = "x" @@ -5673,15 +5585,15 @@ libraries = ["dl"] link_library_dirs = ["lib"] )"; auto m = mcpp::manifest::parse_string(only_runtime); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->conditionalConfigs.size(), 1u) + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->conditionalConfigs.size(), 1u))) << "a runtime-only predicate was parsed and then discarded"; - ASSERT_EQ(m->conditionalConfigs[0].libraries.size(), 1u); - EXPECT_EQ(m->conditionalConfigs[0].libraries[0], "dl"); - EXPECT_EQ(m->conditionalConfigs[0].linkLibraryDirs.size(), 1u); -} + expect(fatal(eq(m->conditionalConfigs[0].libraries.size(), 1u))); + expect(eq(m->conditionalConfigs[0].libraries[0], "dl")); + expect(eq(m->conditionalConfigs[0].linkLibraryDirs.size(), 1u)); +}}; -TEST(Manifest, AConditionalRuntimeTableWithAnUnrelatedSiblingIsAlsoRecorded) { +UTest a_conditional_runtime_table_with_an_unrelated_sibling_is_also_recorded{"Manifest/AConditionalRuntimeTableWithAnUnrelatedSiblingIsAlsoRecorded", [] { // The control for the test above. This shape ALWAYS worked, which is why // the defect survived: every fixture that exercised the neutral link intent // was a generated distribution package, and those carry `ldflags` too. @@ -5695,16 +5607,16 @@ libraries = ["dl"] defines = ["UNRELATED_TO_LIBRARIES"] )"; auto m = mcpp::manifest::parse_string(with_sibling); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->conditionalConfigs.size(), 1u); - ASSERT_EQ(m->conditionalConfigs[0].libraries.size(), 1u); - EXPECT_EQ(m->conditionalConfigs[0].libraries[0], "dl"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->conditionalConfigs.size(), 1u))); + expect(fatal(eq(m->conditionalConfigs[0].libraries.size(), 1u))); + expect(eq(m->conditionalConfigs[0].libraries[0], "dl")); +}}; // An unsupported key in `[runtime]` is REPORTED, not dropped — the rule // `[build]`, `[target.]` and `[target..build]` have each followed // since #418/#249/#544, and which these two tables did not. -TEST(Manifest, AnUnknownRuntimeKeyIsReported) { +UTest an_unknown_runtime_key_is_reported{"Manifest/AnUnknownRuntimeKeyIsReported", [] { constexpr auto src = R"( [package] name = "x" @@ -5714,19 +5626,18 @@ libraries = ["m"] dlopen_lib = ["one"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->schemaWarnings.size(), 1u); - EXPECT_NE(m->schemaWarnings[0].find("dlopen_lib"), std::string::npos) - << m->schemaWarnings[0]; + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->schemaWarnings.size(), 1u))); + if (auto&& failed = expect(ne(m->schemaWarnings[0].find("dlopen_lib"), std::string::npos)); !failed) failed << m->schemaWarnings[0]; // The message names the list the check uses, so a reader can find the key // they meant without leaving the message. - EXPECT_NE(m->schemaWarnings[0].find("dlopen_libs"), std::string::npos) + expect(ne(m->schemaWarnings[0].find("dlopen_libs"), std::string::npos)) << m->schemaWarnings[0]; // The correctly spelled sibling still took effect. - ASSERT_EQ(m->runtimeConfig.linkIntent.libraries.size(), 1u); -} + expect(fatal(eq(m->runtimeConfig.linkIntent.libraries.size(), 1u))); +}}; -TEST(Manifest, AnUnknownConditionalRuntimeKeyIsReported) { +UTest an_unknown_conditional_runtime_key_is_reported{"Manifest/AnUnknownConditionalRuntimeKeyIsReported", [] { constexpr auto src = R"( [package] name = "x" @@ -5736,20 +5647,19 @@ libraries = ["dl"] deploy_files = ["x"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->schemaWarnings.size(), 1u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->schemaWarnings.size(), 1u))); // `deploy_files` is a real `[runtime]` key and not a per-target one, so // the plausible-looking case is the one held here. (`frameworks` was the // example until #622 made it the third per-target key.) - EXPECT_NE(m->schemaWarnings[0].find("deploy_files"), std::string::npos) - << m->schemaWarnings[0]; - EXPECT_NE(m->schemaWarnings[0].find("[target.linux.runtime]"), std::string::npos) + expect(ne(m->schemaWarnings[0].find("deploy_files"), std::string::npos)) << m->schemaWarnings[0]; -} + if (auto&& failed = expect(ne(m->schemaWarnings[0].find("[target.linux.runtime]"), std::string::npos)); !failed) failed << m->schemaWarnings[0]; +}}; // The negative control for both sweeps. Without it they pass against a parser // that reports every key, which reads identically in a green suite. -TEST(Manifest, CorrectlySpelledRuntimeKeysAreSilent) { +UTest correctly_spelled_runtime_keys_are_silent{"Manifest/CorrectlySpelledRuntimeKeysAreSilent", [] { constexpr auto src = R"( [package] name = "x" @@ -5780,37 +5690,37 @@ libraries = ["dl"] link_library_dirs = ["lib"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); std::string all; for (auto const& w : m->schemaWarnings) { all += w; all += '\n'; } - EXPECT_TRUE(m->schemaWarnings.empty()) << all; + expect(m->schemaWarnings.empty()) << all; // A `[runtime.]` sub-table is a provider override, not a typo. - EXPECT_EQ(m->runtimeConfig.providerOverrides.at("somecapability"), "pkg"); + expect(eq(m->runtimeConfig.providerOverrides.at("somecapability"), "pkg")); // `[[runtime.artifacts]]` and `[[runtime.requirements]]` are arrays, not // tables, so they reach the sweep rather than being skipped with the // provider channel. They are exactly what `mcpp pack` emits into every // packed library, so a false positive here would warn on all of them. - EXPECT_EQ(m->runtimeConfig.artifacts.size(), 1u); - EXPECT_EQ(m->runtimeConfig.requirements.size(), 1u); -} + expect(eq(m->runtimeConfig.artifacts.size(), 1u)); + expect(eq(m->runtimeConfig.requirements.size(), 1u)); +}}; // A manifest authored on Windows commonly begins with a UTF-8 byte-order mark. // It used to reach the lexer as a bare key and produce `1:1: expected key`, a // true statement about the token that says nothing about the file. -TEST(Manifest, AByteOrderMarkOnTheManifestIsNotAnError) { +UTest a_byte_order_mark_on_the_manifest_is_not_an_error{"Manifest/AByteOrderMarkOnTheManifestIsNotAnError", [] { const std::string src = "\xEF\xBB\xBF" "[package]\n" "name = \"x\"\n" "version = \"0.1.0\"\n"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->package.name, "x"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->package.name, "x")); +}}; // ── #615: `runtime.deploy` ────────────────────────────────────────────────── -TEST(Manifest, RuntimeDeployParsesFromAndTo) { +UTest runtime_deploy_parses_from_and_to{"Manifest/RuntimeDeployParsesFromAndTo", [] { constexpr auto src = R"( [package] name = "icd" @@ -5822,19 +5732,19 @@ deploy = [ ] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto const& d = m->runtimeConfig.linkIntent.deploy; - ASSERT_EQ(d.size(), 2u); - EXPECT_EQ(d[0].from, std::filesystem::path("share/vulkan/icd.d/lvp_icd.json")); - EXPECT_EQ(d[0].to, "vulkan/icd.d"); - EXPECT_EQ(d[1].from, std::filesystem::path("share/readme.txt")); - EXPECT_EQ(d[1].to, "."); + expect(fatal(eq(d.size(), 2u))); + expect(eq(d[0].from, std::filesystem::path("share/vulkan/icd.d/lvp_icd.json"))); + expect(eq(d[0].to, "vulkan/icd.d")); + expect(eq(d[1].from, std::filesystem::path("share/readme.txt"))); + expect(eq(d[1].to, ".")); // A key of its own: `deploy_files` is untouched. - EXPECT_TRUE(m->runtimeConfig.linkIntent.deployFiles.empty()); - EXPECT_TRUE(m->schemaWarnings.empty()); -} + expect(m->runtimeConfig.linkIntent.deployFiles.empty()); + expect(m->schemaWarnings.empty()); +}}; -TEST(Manifest, RuntimeDeployRefusesEachMalformedEntryNamingIt) { +UTest runtime_deploy_refuses_each_malformed_entry_naming_it{"Manifest/RuntimeDeployRefusesEachMalformedEntryNamingIt", [] { const std::pair cases[] = { {R"({ from = "a.json", to = "../outside" })", "`to` has a `.` or `..` component"}, {R"({ from = "/etc/a.json", to = "x" })", "`from` is absolute"}, @@ -5856,17 +5766,15 @@ version = "0.1.0" deploy = [ {} ] )", entry); auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()) << entry; - EXPECT_NE(m.error().message.find(expected), std::string::npos) - << entry << " -> " << m.error().message; - EXPECT_NE(m.error().message.find("runtime.deploy[1]"), std::string::npos) - << m.error().message; + expect(fatal(!m.has_value())) << entry; + if (auto&& failed = expect(ne(m.error().message.find(expected), std::string::npos)); !failed) failed << entry << " -> " << m.error().message; + if (auto&& failed = expect(ne(m.error().message.find("runtime.deploy[1]"), std::string::npos)); !failed) failed << m.error().message; } -} +}}; // ── `requires_abi` (design 2026-09-12, section 5.2) ──────────────────────── -TEST(Manifest, RequiresAbiThreadsParsesOnThePackageAndOnAFeature) { +UTest requires_abi_threads_parses_on_the_package_and_on_a_feature{"Manifest/RequiresAbiThreadsParsesOnThePackageAndOnAFeature", [] { constexpr auto src = R"( [package] name = "wasmrt" @@ -5877,20 +5785,20 @@ mt = { requires_abi = { threads = true } } st = { requires_abi = { threads = false } } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_TRUE(m->requiresAbiThreads); - ASSERT_TRUE(m->featureRequiresAbiThreads.contains("mt")); - EXPECT_TRUE(m->featureRequiresAbiThreads.at("mt")); - ASSERT_TRUE(m->featureRequiresAbiThreads.contains("st")); - EXPECT_FALSE(m->featureRequiresAbiThreads.at("st")); - EXPECT_TRUE(m->schemaWarnings.empty()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(bool(m->requiresAbiThreads)); + expect(fatal(m->featureRequiresAbiThreads.contains("mt"))); + expect(bool(m->featureRequiresAbiThreads.at("mt"))); + expect(fatal(m->featureRequiresAbiThreads.contains("st"))); + expect(!(m->featureRequiresAbiThreads.at("st"))); + expect(m->schemaWarnings.empty()); +}}; // `exceptions` joined `threads` as the second `abi` member 2026-09-12 (the UI // framework record, section 2.1, A1); the accepted set and every refusal // below name both. See tests/unit/test_abi.cpp for the member's own coverage // (parsing, rendering, the target-axis `requires_abi` forms). -TEST(Manifest, AbiTablesRefuseAnythingButABooleanMember) { +UTest abi_tables_refuse_anything_but_a_boolean_member{"Manifest/AbiTablesRefuseAnythingButABooleanMember", [] { const std::pair cases[] = { {"[package]\nname = \"a\"\nversion = \"0.1.0\"\nrequires_abi = true\n", "[package] requires_abi must be a table such as `{ threads = true }`"}, @@ -5905,18 +5813,17 @@ TEST(Manifest, AbiTablesRefuseAnythingButABooleanMember) { }; for (auto [src, expected] : cases) { auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()) << src; - EXPECT_NE(m.error().message.find(expected), std::string::npos) - << src << " -> " << m.error().message; + expect(fatal(!m.has_value())) << src; + if (auto&& failed = expect(ne(m.error().message.find(expected), std::string::npos)); !failed) failed << src << " -> " << m.error().message; } -} +}}; // ── The per-target tool declaration (design 2026-09-12, section 5.1) ─────── // // A package written directly under `[target..xlings]` is refused, and // the refusal names the table that does accept it, which is the statement the // SDK batch's record was missing when it concluded the declaration did not exist. -TEST(Manifest, TargetXlingsRefusalNamesTheWorkspaceTable) { +UTest target_xlings_refusal_names_the_workspace_table{"Manifest/TargetXlingsRefusalNamesTheWorkspaceTable", [] { constexpr auto src = R"( [package] name = "app" @@ -5925,20 +5832,18 @@ version = "0.1.0" "xim:apple-simulator-tools" = "" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find( - "[target.aarch64-ios-sim.xlings] does not accept 'xim:apple-simulator-tools'"), - std::string::npos) << m.error().message; - EXPECT_NE(m.error().message.find("[target.aarch64-ios-sim.xlings.workspace]"), - std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find( + "[target.aarch64-ios-sim.xlings] does not accept 'xim:apple-simulator-tools'"), std::string::npos)); !failed) failed << m.error().message; + if (auto&& failed = expect(ne(m.error().message.find("[target.aarch64-ios-sim.xlings.workspace]"), std::string::npos)); !failed) failed << m.error().message; +}}; // ─── `linkage` beside `kind`: a library's default form (#642 E1) ──────────── // // `kind = "shared"` is a constraint; `linkage` is the answer a consumer gets // when it asks for nothing. The parse accepts the default on a library, refuses // it beside the constraint and on a program, and a row states one of the two. -TEST(Manifest, TargetLinkageIsParsedAsTheLibrarysDefaultForm) { +UTest target_linkage_is_parsed_as_the_librarys_default_form{"Manifest/TargetLinkageIsParsedAsTheLibrarysDefaultForm", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "fw" @@ -5947,17 +5852,17 @@ version = "0.1.0" kind = "lib" linkage = "shared" )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets[0].kind, mcpp::manifest::Target::Library); - EXPECT_EQ(m->targets[0].linkageDefault, "shared"); - EXPECT_EQ(m->targets[0].linkageDeclaredBy, "[targets.fw] linkage = \"shared\""); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->targets.size(), 1u))); + expect(eq(m->targets[0].kind, mcpp::manifest::Target::Library)); + expect(eq(m->targets[0].linkageDefault, "shared")); + expect(eq(m->targets[0].linkageDeclaredBy, "[targets.fw] linkage = \"shared\"")); // A known key: no schema warning. for (auto const& w : m->schemaWarnings) - EXPECT_EQ(w.find("'linkage'"), std::string::npos) << w; -} + expect(eq(w.find("'linkage'"), std::string::npos)) << w; +}}; -TEST(Manifest, TargetLinkageIsRefusedBesideKindSharedAndOnAProgram) { +UTest target_linkage_is_refused_beside_kind_shared_and_on_a_program{"Manifest/TargetLinkageIsRefusedBesideKindSharedAndOnAProgram", [] { auto both = mcpp::manifest::parse_string(R"( [package] name = "fw" @@ -5966,10 +5871,9 @@ version = "0.1.0" kind = "shared" linkage = "static" )"); - ASSERT_FALSE(both.has_value()); - EXPECT_NE(both.error().message.find( - "states both `kind = \"shared\"` and `linkage = \"static\"`"), - std::string::npos) << both.error().message; + expect(fatal(!both.has_value())); + if (auto&& failed = expect(ne(both.error().message.find( + "states both `kind = \"shared\"` and `linkage = \"static\"`"), std::string::npos)); !failed) failed << both.error().message; auto program = mcpp::manifest::parse_string(R"( [package] @@ -5980,9 +5884,8 @@ kind = "bin" main = "src/main.cpp" linkage = "shared" )"); - ASSERT_FALSE(program.has_value()); - EXPECT_NE(program.error().message.find("a program target has no link form"), - std::string::npos) << program.error().message; + expect(fatal(!program.has_value())); + if (auto&& failed = expect(ne(program.error().message.find("a program target has no link form"), std::string::npos)); !failed) failed << program.error().message; auto value = mcpp::manifest::parse_string(R"( [package] @@ -5992,12 +5895,11 @@ version = "0.1.0" kind = "lib" linkage = "dynamic" )"); - ASSERT_FALSE(value.has_value()); - EXPECT_NE(value.error().message.find("`static` or `shared`"), std::string::npos) - << value.error().message; -} + expect(fatal(!value.has_value())); + if (auto&& failed = expect(ne(value.error().message.find("`static` or `shared`"), std::string::npos)); !failed) failed << value.error().message; +}}; -TEST(Manifest, ARowStatesEitherKindOrLinkage) { +UTest a_row_states_either_kind_or_linkage{"Manifest/ARowStatesEitherKindOrLinkage", [] { auto ok = mcpp::manifest::parse_string(R"( [package] name = "fw" @@ -6007,15 +5909,14 @@ kind = "lib" [target.'cfg(os = "linux")'.targets.fw] linkage = "shared" )"); - ASSERT_TRUE(ok.has_value()) << ok.error().format(); - ASSERT_EQ(ok->conditionalConfigs.size(), 1u); + if (auto&& failed = expect(fatal(ok.has_value())); !failed) failed << ok.error().format(); + expect(fatal(eq(ok->conditionalConfigs.size(), 1u))); auto const& rows = ok->conditionalConfigs[0].targetKinds; - ASSERT_EQ(rows.count("fw"), 1u); - EXPECT_EQ(rows.at("fw").linkage, "shared"); - EXPECT_EQ(rows.at("fw").statement, - "[target.'cfg(os = \"linux\")'.targets.fw] linkage = \"shared\""); + expect(fatal(eq(rows.count("fw"), 1u))); + expect(eq(rows.at("fw").linkage, "shared")); + expect(eq(rows.at("fw").statement, "[target.'cfg(os = \"linux\")'.targets.fw] linkage = \"shared\"")); for (auto const& w : ok->schemaWarnings) - EXPECT_EQ(w.find("unsupported key"), std::string::npos) << w; + expect(eq(w.find("unsupported key"), std::string::npos)) << w; auto both = mcpp::manifest::parse_string(R"( [package] @@ -6027,16 +5928,15 @@ kind = "lib" kind = "shared" linkage = "shared" )"); - ASSERT_FALSE(both.has_value()); - EXPECT_NE(both.error().message.find("states both `kind` and `linkage`"), - std::string::npos) << both.error().message; -} + expect(fatal(!both.has_value())); + if (auto&& failed = expect(ne(both.error().message.find("states both `kind` and `linkage`"), std::string::npos)); !failed) failed << both.error().message; +}}; namespace { mcpp::manifest::Manifest merged_for_linux(std::string_view src) { auto m = mcpp::manifest::parse_string(src); - EXPECT_TRUE(m.has_value()) << (m.has_value() ? "" : m.error().format()); + if (auto&& failed = expect(m.has_value()); !failed) failed << (m.has_value() ? "" : m.error().format()); if (!m) return {}; mcpp::build::merge_conditional_config( *m, mcpp::build::cfgpred::context_for("x86_64-unknown-linux-gnu")); @@ -6045,7 +5945,7 @@ mcpp::manifest::Manifest merged_for_linux(std::string_view src) { } // namespace -TEST(Manifest, ARowDefaultReplacesAnUnconditionalKindShared) { +UTest a_row_default_replaces_an_unconditional_kind_shared{"Manifest/ARowDefaultReplacesAnUnconditionalKindShared", [] { auto m = merged_for_linux(R"( [package] name = "fw" @@ -6055,12 +5955,12 @@ kind = "shared" [target.'cfg(os = "linux")'.targets.fw] linkage = "static" )"); - ASSERT_EQ(m.targets.size(), 1u); - EXPECT_EQ(m.targets[0].kind, mcpp::manifest::Target::Library); - EXPECT_EQ(m.targets[0].linkageDefault, "static"); -} + expect(fatal(eq(m.targets.size(), 1u))); + expect(eq(m.targets[0].kind, mcpp::manifest::Target::Library)); + expect(eq(m.targets[0].linkageDefault, "static")); +}}; -TEST(Manifest, ARowKindClearsAnUnconditionalDefault) { +UTest a_row_kind_clears_an_unconditional_default{"Manifest/ARowKindClearsAnUnconditionalDefault", [] { auto m = merged_for_linux(R"( [package] name = "fw" @@ -6071,12 +5971,12 @@ linkage = "shared" [target.'cfg(os = "linux")'.targets.fw] kind = "lib" )"); - ASSERT_EQ(m.targets.size(), 1u); - EXPECT_EQ(m.targets[0].kind, mcpp::manifest::Target::Library); - EXPECT_TRUE(m.targets[0].linkageDefault.empty()); -} + expect(fatal(eq(m.targets.size(), 1u))); + expect(eq(m.targets[0].kind, mcpp::manifest::Target::Library)); + expect(m.targets[0].linkageDefault.empty()); +}}; -TEST(Manifest, ARowThatDoesNotMatchLeavesTheUnconditionalStatement) { +UTest a_row_that_does_not_match_leaves_the_unconditional_statement{"Manifest/ARowThatDoesNotMatchLeavesTheUnconditionalStatement", [] { auto m = merged_for_linux(R"( [package] name = "fw" @@ -6087,14 +5987,14 @@ linkage = "shared" [target.'cfg(os = "windows")'.targets.fw] kind = "shared" )"); - ASSERT_EQ(m.targets.size(), 1u); - EXPECT_EQ(m.targets[0].kind, mcpp::manifest::Target::Library); - EXPECT_EQ(m.targets[0].linkageDefault, "shared"); -} + expect(fatal(eq(m.targets.size(), 1u))); + expect(eq(m.targets[0].kind, mcpp::manifest::Target::Library)); + expect(eq(m.targets[0].linkageDefault, "shared")); +}}; // #709: `[features.] tools` names the package's own bin targets, and is // checked once inference has produced the target list. -TEST(Manifest, AFeatureToolMustNameABinTargetOfThePackage) { +UTest a_feature_tool_must_name_a_bin_target_of_the_package{"Manifest/AFeatureToolMustNameABinTargetOfThePackage", [] { auto dir = std::filesystem::temp_directory_path() / std::format("mcpp_feature_tools_{}", std::random_device{}()); std::filesystem::create_directories(dir / "src"); @@ -6107,19 +6007,19 @@ TEST(Manifest, AFeatureToolMustNameABinTargetOfThePackage) { }; write("gen-tool"); auto ok = mcpp::manifest::load(dir / "mcpp.toml"); - ASSERT_TRUE(ok) << (ok ? "" : ok.error().message); - ASSERT_EQ(ok->featureTools.at("codegen").size(), 1u); - EXPECT_EQ(ok->featureTools.at("codegen")[0], "gen-tool"); + if (auto&& failed = expect(fatal(bool(ok))); !failed) failed << (ok ? "" : ok.error().message); + expect(fatal(eq(ok->featureTools.at("codegen").size(), 1u))); + expect(eq(ok->featureTools.at("codegen")[0], "gen-tool")); write("nosuch"); auto bad = mcpp::manifest::load(dir / "mcpp.toml"); - ASSERT_FALSE(bad); - EXPECT_NE(bad.error().message.find("nosuch"), std::string::npos) << bad.error().message; - EXPECT_NE(bad.error().message.find("gen-tool"), std::string::npos) << bad.error().message; + expect(fatal(!bad)); + if (auto&& failed = expect(ne(bad.error().message.find("nosuch"), std::string::npos)); !failed) failed << bad.error().message; + if (auto&& failed = expect(ne(bad.error().message.find("gen-tool"), std::string::npos)); !failed) failed << bad.error().message; std::error_code ec; std::filesystem::remove_all(dir, ec); -} +}}; // #714: `sources = []` states that the default build compiles nothing, so a // module interface under `src/` is not a library of that build. A build-logic @@ -6127,7 +6027,7 @@ TEST(Manifest, AFeatureToolMustNameABinTargetOfThePackage) { // request; inferring a library for it made every build of the package link an // archive with no inputs. A glob that happens to match nothing is a different // statement and keeps the inferred library (and #533's empty-link refusal). -TEST(Manifest, AnExplicitlyEmptySourceListInfersNoLibrary) { +UTest an_explicitly_empty_source_list_infers_no_library{"Manifest/AnExplicitlyEmptySourceListInfersNoLibrary", [] { auto dir = std::filesystem::temp_directory_path() / std::format("mcpp_empty_sources_{}", std::random_device{}()); std::filesystem::create_directories(dir / "src"); @@ -6139,8 +6039,8 @@ TEST(Manifest, AnExplicitlyEmptySourceListInfersNoLibrary) { "[features.host]\nsources = [\"src/buildlib.cppm\"]\n"; } auto empty = mcpp::manifest::load(dir / "mcpp.toml"); - ASSERT_TRUE(empty) << (empty ? "" : empty.error().message); - EXPECT_TRUE(empty->targets.empty()); + if (auto&& failed = expect(fatal(bool(empty))); !failed) failed << (empty ? "" : empty.error().message); + expect(empty->targets.empty()); { std::ofstream(dir / "mcpp.toml") @@ -6148,19 +6048,19 @@ TEST(Manifest, AnExplicitlyEmptySourceListInfersNoLibrary) { "[build]\nsources = [\"srcs/**/*.cppm\"]\n"; } auto typo = mcpp::manifest::load(dir / "mcpp.toml"); - ASSERT_TRUE(typo) << (typo ? "" : typo.error().message); - ASSERT_EQ(typo->targets.size(), 1u); - EXPECT_EQ(typo->targets[0].kind, mcpp::manifest::Target::Library); + if (auto&& failed = expect(fatal(bool(typo))); !failed) failed << (typo ? "" : typo.error().message); + expect(fatal(eq(typo->targets.size(), 1u))); + expect(eq(typo->targets[0].kind, mcpp::manifest::Target::Library)); std::error_code ec; std::filesystem::remove_all(dir, ec); -} +}}; // #649 E6: a consumer reads "every declared target is a program" as "a tool // provider that contributes nothing to my graph". A target list the loader // INFERRED from the tree is not that statement, so the loader says which of // the two it produced. -TEST(Manifest, TargetsInferredIsDistinguishedFromDeclared) { +UTest targets_inferred_is_distinguished_from_declared{"Manifest/TargetsInferredIsDistinguishedFromDeclared", [] { auto dir = std::filesystem::temp_directory_path() / std::format("mcpp_targets_inferred_{}", std::random_device{}()); std::filesystem::create_directories(dir / "src"); @@ -6169,10 +6069,10 @@ TEST(Manifest, TargetsInferredIsDistinguishedFromDeclared) { std::ofstream(dir / "mcpp.toml") << "[package]\nname = \"inferred\"\nversion = \"0.1.0\"\n"; } auto inferred = mcpp::manifest::load(dir / "mcpp.toml"); - ASSERT_TRUE(inferred) << (inferred ? "" : inferred.error().message); - ASSERT_EQ(inferred->targets.size(), 1u); - EXPECT_EQ(inferred->targets[0].kind, mcpp::manifest::Target::Binary); - EXPECT_TRUE(inferred->targetsInferred); + if (auto&& failed = expect(fatal(bool(inferred))); !failed) failed << (inferred ? "" : inferred.error().message); + expect(fatal(eq(inferred->targets.size(), 1u))); + expect(eq(inferred->targets[0].kind, mcpp::manifest::Target::Binary)); + expect(bool(inferred->targetsInferred)); { std::ofstream(dir / "mcpp.toml") @@ -6180,20 +6080,20 @@ TEST(Manifest, TargetsInferredIsDistinguishedFromDeclared) { "[targets.declared]\nkind = \"bin\"\nmain = \"src/main.cpp\"\n"; } auto declared = mcpp::manifest::load(dir / "mcpp.toml"); - ASSERT_TRUE(declared) << (declared ? "" : declared.error().message); - ASSERT_EQ(declared->targets.size(), 1u); - EXPECT_FALSE(declared->targetsInferred); + if (auto&& failed = expect(fatal(bool(declared))); !failed) failed << (declared ? "" : declared.error().message); + expect(fatal(eq(declared->targets.size(), 1u))); + expect(!declared->targetsInferred); std::error_code ec; std::filesystem::remove_all(dir, ec); -} +}}; // ── #647 E1 / #649 X4: `[package.metadata]` and the `[package]` key check ──── // // The metadata table is kept verbatim for the graph document, as JSON text, and // `metadata` is a known key. Any other key the parser does not read is reported // the way `[build]` reports one, so `licence` is no longer accepted in silence. -TEST(Manifest, PackageMetadataIsKeptVerbatimAsJson) { +UTest package_metadata_is_kept_verbatim_as_json{"Manifest/PackageMetadataIsKeptVerbatimAsJson", [] { constexpr auto src = R"( [package] name = "x" @@ -6206,20 +6106,18 @@ enabled = true note = "a \"quoted\" value" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->packageMetadataJson, - R"({"demo":{"count":3,"enabled":true,"langs":["en","zh"],"note":"a \"quoted\" value","resources":"res"}})"); - EXPECT_TRUE(m->schemaWarnings.empty()) - << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->packageMetadataJson, R"({"demo":{"count":3,"enabled":true,"langs":["en","zh"],"note":"a \"quoted\" value","resources":"res"}})")); + if (auto&& failed = expect(m->schemaWarnings.empty()); !failed) failed << (m->schemaWarnings.empty() ? "" : m->schemaWarnings[0]); +}}; -TEST(Manifest, PackageWithoutMetadataHasNone) { +UTest package_without_metadata_has_none{"Manifest/PackageWithoutMetadataHasNone", [] { auto m = mcpp::manifest::parse_string("[package]\nname = \"x\"\nversion = \"0.1.0\"\n"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_TRUE(m->packageMetadataJson.empty()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(m->packageMetadataJson.empty()); +}}; -TEST(Manifest, UnknownPackageKeyIsReported) { +UTest unknown_package_key_is_reported{"Manifest/UnknownPackageKeyIsReported", [] { constexpr auto src = R"( [package] name = "x" @@ -6227,13 +6125,12 @@ version = "0.1.0" licence = "MIT" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->schemaWarnings.size(), 1u); - EXPECT_NE(m->schemaWarnings[0].find("[package] has unsupported key 'licence'"), - std::string::npos) << m->schemaWarnings[0]; -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->schemaWarnings.size(), 1u))); + if (auto&& failed = expect(ne(m->schemaWarnings[0].find("[package] has unsupported key 'licence'"), std::string::npos)); !failed) failed << m->schemaWarnings[0]; +}}; -TEST(Manifest, EveryReadPackageKeyIsKnown) { +UTest every_read_package_key_is_known{"Manifest/EveryReadPackageKeyIsKnown", [] { constexpr auto src = R"( [package] name = "x" @@ -6255,16 +6152,16 @@ std-module-flags = ["-x"] c-environment = "platform" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); for (auto const& w : m->schemaWarnings) - EXPECT_EQ(w.find("[package] has unsupported key"), std::string::npos) << w; - EXPECT_EQ(m->cEnvironment, "platform"); -} + expect(eq(w.find("[package] has unsupported key"), std::string::npos)) << w; + expect(eq(m->cEnvironment, "platform")); +}}; // ── `[c-abi]` — the C environment a `mcpp:c-abi=` provider declares // (design 2026-09-18 §3.2) ────────────────────────────────────────────────── -TEST(Manifest, CAbiBlockParsesOnAProviderOfTheLayer) { +UTest c_abi_block_parses_on_a_provider_of_the_layer{"Manifest/CAbiBlockParsesOnAProviderOfTheLayer", [] { constexpr auto src = R"( [package] name = "openkal-musl" @@ -6278,17 +6175,17 @@ wchar = 32 builtins = "iso" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_TRUE(m->cAbiDecl.has_value()); - EXPECT_TRUE(m->cAbiDecl->declared); - EXPECT_EQ(m->cAbiDecl->presents, mcpp::targetside::CAbiPresents::Posix); - EXPECT_EQ(m->cAbiDecl->dataModel, mcpp::targetside::CAbiDataModel::ArchDefault); - EXPECT_EQ(m->cAbiDecl->wcharBits, 32); - EXPECT_EQ(m->cAbiDecl->builtins, mcpp::targetside::CAbiBuiltins::Iso); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(m->cAbiDecl.has_value())); + expect(bool(m->cAbiDecl->declared)); + expect(eq(m->cAbiDecl->presents, mcpp::targetside::CAbiPresents::Posix)); + expect(eq(m->cAbiDecl->dataModel, mcpp::targetside::CAbiDataModel::ArchDefault)); + expect(eq(m->cAbiDecl->wcharBits, 32)); + expect(eq(m->cAbiDecl->builtins, mcpp::targetside::CAbiBuiltins::Iso)); +}}; // `builtins` alone defaults — the other three carry no default (§3.2). -TEST(Manifest, CAbiBuiltinsDefaultsToPlatform) { +UTest c_abi_builtins_defaults_to_platform{"Manifest/CAbiBuiltinsDefaultsToPlatform", [] { constexpr auto src = R"( [package] name = "openkal-musl" @@ -6301,15 +6198,15 @@ data-model = "arch-default" wchar = 32 )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_TRUE(m->cAbiDecl.has_value()); - EXPECT_EQ(m->cAbiDecl->builtins, mcpp::targetside::CAbiBuiltins::Platform); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(m->cAbiDecl.has_value())); + expect(eq(m->cAbiDecl->builtins, mcpp::targetside::CAbiBuiltins::Platform)); +}}; // A package that does not provide the layer has no standing to state what // environment it presents — an internal contradiction, refused rather than // silently ignored (§3.2, gap #3 of the design's own self-review). -TEST(Manifest, CAbiBlockWithoutProvidingTheLayerIsRefused) { +UTest c_abi_block_without_providing_the_layer_is_refused{"Manifest/CAbiBlockWithoutProvidingTheLayerIsRefused", [] { constexpr auto src = R"( [package] name = "not-a-c-library" @@ -6321,13 +6218,12 @@ data-model = "arch-default" wchar = 32 )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find( - "[package] provides does not list `mcpp:c-abi=`"), - std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find( + "[package] provides does not list `mcpp:c-abi=`"), std::string::npos)); !failed) failed << m.error().message; +}}; -TEST(Manifest, CAbiRequiresAllThreeKeysWithNoDefault) { +UTest c_abi_requires_all_three_keys_with_no_default{"Manifest/CAbiRequiresAllThreeKeysWithNoDefault", [] { const std::pair cases[] = { {R"([package] name = "a" @@ -6356,13 +6252,12 @@ data-model = "arch-default" }; for (auto [src, expected] : cases) { auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()) << src; - EXPECT_NE(m.error().message.find(expected), std::string::npos) - << src << " -> " << m.error().message; + expect(fatal(!m.has_value())) << src; + if (auto&& failed = expect(ne(m.error().message.find(expected), std::string::npos)); !failed) failed << src << " -> " << m.error().message; } -} +}}; -TEST(Manifest, CAbiClosedValueSetsRejectAMisspelling) { +UTest c_abi_closed_value_sets_reject_a_misspelling{"Manifest/CAbiClosedValueSetsRejectAMisspelling", [] { const std::pair cases[] = { {R"([package] name = "a" @@ -6414,15 +6309,14 @@ extra = 1 }; for (auto [src, expected] : cases) { auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()) << src; - EXPECT_NE(m.error().message.find(expected), std::string::npos) - << src << " -> " << m.error().message; + expect(fatal(!m.has_value())) << src; + if (auto&& failed = expect(ne(m.error().message.find(expected), std::string::npos)); !failed) failed << src << " -> " << m.error().message; } -} +}}; // A package with no [c-abi] block resolves `cAbiDecl` to nullopt — the // absent-block case §3.2 requires to be byte-identical to today. -TEST(Manifest, NoCAbiBlockLeavesCAbiDeclEmpty) { +UTest no_c_abi_block_leaves_c_abi_decl_empty{"Manifest/NoCAbiBlockLeavesCAbiDeclEmpty", [] { constexpr auto src = R"( [package] name = "openkal-musl" @@ -6430,12 +6324,12 @@ version = "0.15.0" provides = ["mcpp:c-abi=musl"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_FALSE(m->cAbiDecl.has_value()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(!(m->cAbiDecl.has_value())); +}}; // `[package] c-environment` — design §3.4. The only value is "platform". -TEST(Manifest, CEnvironmentAcceptsOnlyPlatform) { +UTest c_environment_accepts_only_platform{"Manifest/CEnvironmentAcceptsOnlyPlatform", [] { constexpr auto ok = R"( [package] name = "openkal-windows" @@ -6444,8 +6338,8 @@ provides = ["mcpp:kernel-abi=openkal"] c-environment = "platform" )"; auto m = mcpp::manifest::parse_string(ok); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->cEnvironment, "platform"); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->cEnvironment, "platform")); constexpr auto bad = R"( [package] @@ -6454,11 +6348,10 @@ version = "0.1.0" c-environment = "native" )"; auto m2 = mcpp::manifest::parse_string(bad); - ASSERT_FALSE(m2.has_value()); - EXPECT_NE(m2.error().message.find( - "c-environment = \"native\" names no known override"), - std::string::npos) << m2.error().message; -} + expect(fatal(!m2.has_value())); + if (auto&& failed = expect(ne(m2.error().message.find( + "c-environment = \"native\" names no known override"), std::string::npos)); !failed) failed << m2.error().message; +}}; // `c-environment = "platform"` is INFERRED for a `mcpp:kernel-abi=` // provider (coordinator revision, openkal-musl spike: openkal-windows called @@ -6476,7 +6369,7 @@ c-environment = "native" // that prose has no executor — it was contradicted once already (0.16.0: a // disposition accepted for every signal and installed for none). -TEST(Manifest, ACLibraryMayEnumerateWhatItDoesNotSupply) { +UTest ac_library_may_enumerate_what_it_does_not_supply{"Manifest/ACLibraryMayEnumerateWhatItDoesNotSupply", [] { constexpr auto src = R"( [package] name = "openkal-musl" @@ -6494,21 +6387,19 @@ fork = { form = "link" } mprotect = { form = "enosys", note = "openkal has no operation upon a mapping's protection" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_TRUE(m->cAbiDecl.has_value()); - ASSERT_EQ(m->cAbiDecl->absent.size(), 2u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(m->cAbiDecl.has_value())); + expect(fatal(eq(m->cAbiDecl->absent.size(), 2u))); // Sorted by name, so a diagnostic built from this list reads the same way // on every run and a test may index it. - EXPECT_EQ(m->cAbiDecl->absent[0].name, "fork"); - EXPECT_EQ(m->cAbiDecl->absent[0].form, - mcpp::targetside::CAbiAbsentForm::Link); - EXPECT_EQ(m->cAbiDecl->absent[1].name, "mprotect"); - EXPECT_EQ(m->cAbiDecl->absent[1].form, - mcpp::targetside::CAbiAbsentForm::Enosys); - EXPECT_FALSE(m->cAbiDecl->absent[1].note.empty()); -} - -TEST(Manifest, AnAbsenceWithNoNamedShapeIsRefused) { + expect(eq(m->cAbiDecl->absent[0].name, "fork")); + expect(eq(m->cAbiDecl->absent[0].form, mcpp::targetside::CAbiAbsentForm::Link)); + expect(eq(m->cAbiDecl->absent[1].name, "mprotect")); + expect(eq(m->cAbiDecl->absent[1].form, mcpp::targetside::CAbiAbsentForm::Enosys)); + expect(!(m->cAbiDecl->absent[1].note.empty())); +}}; + +UTest an_absence_with_no_named_shape_is_refused{"Manifest/AnAbsenceWithNoNamedShapeIsRefused", [] { // A facility absent in an unnamed shape is one nothing can assert // against, which is the whole reason for writing it down. constexpr auto src = R"( @@ -6526,12 +6417,12 @@ wchar = 32 fork = { note = "no process image duplication" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); + expect(fatal(!m.has_value())); auto msg = m.error().format(); - EXPECT_NE(msg.find("form"), std::string::npos) << msg; -} + expect(ne(msg.find("form"), std::string::npos)) << msg; +}}; -TEST(Manifest, AnUnknownAbsentShapeNamesTheThreeThatExist) { +UTest an_unknown_absent_shape_names_the_three_that_exist{"Manifest/AnUnknownAbsentShapeNamesTheThreeThatExist", [] { constexpr auto src = R"( [package] name = "openkal-musl" @@ -6547,12 +6438,12 @@ wchar = 32 fork = { form = "sometimes" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); + expect(fatal(!m.has_value())); auto msg = m.error().format(); - EXPECT_NE(msg.find("accepted-no-effect"), std::string::npos) << msg; -} + expect(ne(msg.find("accepted-no-effect"), std::string::npos)) << msg; +}}; -TEST(Manifest, TheAcceptedNoEffectShapeHasAName) { +UTest the_accepted_no_effect_shape_has_a_name{"Manifest/TheAcceptedNoEffectShapeHasAName", [] { // It is the shape of the defect openkal-musl 0.16.0 repaired: a call that // succeeds and does not do part of what it was asked. Naming it is what // makes "how many of these are there" a question with an answer. @@ -6571,13 +6462,12 @@ wchar = 32 tcsetattr = { form = "accepted-no-effect", note = "the fields openkal does not name are not applied" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->cAbiDecl->absent.size(), 1u); - EXPECT_EQ(m->cAbiDecl->absent[0].form, - mcpp::targetside::CAbiAbsentForm::AcceptedNoEffect); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->cAbiDecl->absent.size(), 1u))); + expect(eq(m->cAbiDecl->absent[0].form, mcpp::targetside::CAbiAbsentForm::AcceptedNoEffect)); +}}; -TEST(Manifest, TheAbsenceTableIsTopLevelSoOlderEnginesIgnoreIt) { +UTest the_absence_table_is_top_level_so_older_engines_ignore_it{"Manifest/TheAbsenceTableIsTopLevelSoOlderEnginesIgnoreIt", [] { // WHY THIS TABLE IS NOT `[c-abi].absent`, WHICH READS BETTER. Measured // against the genuine published 2026.9.18.3 archive -- the index floor // when this shipped -- on the exact manifest openkal-musl 0.17.0 @@ -6605,13 +6495,13 @@ wchar = 32 absent = { fork = { form = "link" } } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); + expect(fatal(!m.has_value())); auto msg = m.error().format(); - EXPECT_NE(msg.find("has no member 'absent'"), std::string::npos) << msg; - EXPECT_NE(msg.find("[c-abi-absent]"), std::string::npos) << msg; -} + expect(ne(msg.find("has no member 'absent'"), std::string::npos)) << msg; + expect(ne(msg.find("[c-abi-absent]"), std::string::npos)) << msg; +}}; -TEST(Manifest, AbsencesMayBeStatedWithoutAnEnvironmentDeclaration) { +UTest absences_may_be_stated_without_an_environment_declaration{"Manifest/AbsencesMayBeStatedWithoutAnEnvironmentDeclaration", [] { // The two tables are independent. A C library whose environment identity // is already its target's default has nothing to put in [c-abi], and is // still entitled to say what it does not supply. `declared` stays false, @@ -6626,14 +6516,14 @@ provides = ["mcpp:c-abi=musl"] fork = { form = "link" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_TRUE(m->cAbiDecl.has_value()); - EXPECT_FALSE(m->cAbiDecl->declared); - ASSERT_EQ(m->cAbiDecl->absent.size(), 1u); - EXPECT_EQ(m->cAbiDecl->absent[0].name, "fork"); -} - -TEST(Manifest, OnlyTheCLibraryMayStateWhatIsAbsent) { + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(m->cAbiDecl.has_value())); + expect(!m->cAbiDecl->declared); + expect(fatal(eq(m->cAbiDecl->absent.size(), 1u))); + expect(eq(m->cAbiDecl->absent[0].name, "fork")); +}}; + +UTest only_the_c_library_may_state_what_is_absent{"Manifest/OnlyTheCLibraryMayStateWhatIsAbsent", [] { // The gate [c-abi] applies, for the reason it applies it. Moving the // table to the top level moved it out from behind that gate, so the gate // is restated here rather than inherited -- and this is the test that @@ -6647,12 +6537,12 @@ version = "0.1.0" fork = { form = "link" } )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()); + expect(fatal(!m.has_value())); auto msg = m.error().format(); - EXPECT_NE(msg.find("mcpp:c-abi="), std::string::npos) << msg; -} + expect(ne(msg.find("mcpp:c-abi="), std::string::npos)) << msg; +}}; -TEST(Manifest, ACLibraryThatEnumeratesNothingHasClaimedNothing) { +UTest ac_library_that_enumerates_nothing_has_claimed_nothing{"Manifest/ACLibraryThatEnumeratesNothingHasClaimedNothing", [] { constexpr auto src = R"( [package] name = "openkal-musl" @@ -6665,10 +6555,10 @@ data-model = "arch-default" wchar = 32 )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_TRUE(m->cAbiDecl.has_value()); - EXPECT_TRUE(m->cAbiDecl->absent.empty()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(m->cAbiDecl.has_value())); + expect(m->cAbiDecl->absent.empty()); +}}; // ── [kernel-abi] provides-interfaces / requires-interfaces ───────────────── // @@ -6677,7 +6567,7 @@ wchar = 32 // real names in one direction and an invented one in the other, and neither // changes what the parser does. -TEST(Manifest, AKernelAbiProviderMayStateWhichInterfacesItProvides) { +UTest a_kernel_abi_provider_may_state_which_interfaces_it_provides{"Manifest/AKernelAbiProviderMayStateWhichInterfacesItProvides", [] { constexpr auto src = R"( [package] name = "openkal-windows" @@ -6688,13 +6578,13 @@ provides = ["mcpp:kernel-abi=openkal"] provides-interfaces = ["openkal.abort", "openkal.stream", "openkal.memory"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->kernelAbiProvidesInterfaces.size(), 3u); - EXPECT_EQ(m->kernelAbiProvidesInterfaces[0], "openkal.abort"); - EXPECT_TRUE(m->kernelAbiRequiresInterfaces.empty()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->kernelAbiProvidesInterfaces.size(), 3u))); + expect(eq(m->kernelAbiProvidesInterfaces[0], "openkal.abort")); + expect(m->kernelAbiRequiresInterfaces.empty()); +}}; -TEST(Manifest, APackageThatDoesNotSupplyTheLayerMayNotStateWhatItProvides) { +UTest a_package_that_does_not_supply_the_layer_may_not_state_what_it_provides{"Manifest/APackageThatDoesNotSupplyTheLayerMayNotStateWhatItProvides", [] { // The same rule `[c-abi]` follows, and for the same reason: a package // without the layer is stating a fact about something it does not have. constexpr auto src = R"( @@ -6706,14 +6596,14 @@ version = "1.0.0" provides-interfaces = ["openkal.fs"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()) + expect(fatal(!m.has_value())) << "a package with no `mcpp:kernel-abi=` in provides must be " "refused when it states provides-interfaces"; auto msg = m.error().format(); - EXPECT_NE(msg.find("mcpp:kernel-abi"), std::string::npos) << msg; -} + expect(ne(msg.find("mcpp:kernel-abi"), std::string::npos)) << msg; +}}; -TEST(Manifest, AnyConsumerMayStateWhichInterfacesItRequires) { +UTest any_consumer_may_state_which_interfaces_it_requires{"Manifest/AnyConsumerMayStateWhichInterfacesItRequires", [] { // `requires-interfaces` is a statement about the package making it, so it // carries no such restriction. openkal SPEC 0.14 §3.3: "a consumer states // its required set per target, in its own package, which is where a @@ -6727,12 +6617,12 @@ version = "1.0.0" requires-interfaces = ["openkal.fs", "openkal.net"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->kernelAbiRequiresInterfaces.size(), 2u); - EXPECT_TRUE(m->kernelAbiProvidesInterfaces.empty()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->kernelAbiRequiresInterfaces.size(), 2u))); + expect(m->kernelAbiProvidesInterfaces.empty()); +}}; -TEST(Manifest, AnUnknownKernelAbiMemberIsAParseErrorNamingTheKey) { +UTest an_unknown_kernel_abi_member_is_a_parse_error_naming_the_key{"Manifest/AnUnknownKernelAbiMemberIsAParseErrorNamingTheKey", [] { constexpr auto src = R"( [package] name = "openkal-linux" @@ -6743,12 +6633,12 @@ provides = ["mcpp:kernel-abi=openkal"] provides-interface = ["openkal.fs"] )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_FALSE(m.has_value()) << "a misspelled member must not be ignored"; + expect(fatal(!m.has_value())) << "a misspelled member must not be ignored"; auto msg = m.error().format(); - EXPECT_NE(msg.find("provides-interface"), std::string::npos) << msg; -} + expect(ne(msg.find("provides-interface"), std::string::npos)) << msg; +}}; -TEST(Manifest, APackageWithNoKernelAbiTableStatesNothing) { +UTest a_package_with_no_kernel_abi_table_states_nothing{"Manifest/APackageWithNoKernelAbiTableStatesNothing", [] { // The guarantee every addition to this parser owes the graph that predates // it: a manifest that writes nothing here is byte-identical in effect. constexpr auto src = R"( @@ -6757,12 +6647,12 @@ name = "some-library" version = "1.0.0" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_TRUE(m->kernelAbiProvidesInterfaces.empty()); - EXPECT_TRUE(m->kernelAbiRequiresInterfaces.empty()); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(m->kernelAbiProvidesInterfaces.empty()); + expect(m->kernelAbiRequiresInterfaces.empty()); +}}; -TEST(Manifest, CEnvironmentIsInferredForAKernelAbiProvider) { +UTest c_environment_is_inferred_for_a_kernel_abi_provider{"Manifest/CEnvironmentIsInferredForAKernelAbiProvider", [] { constexpr auto kernelAbiPkg = R"( [package] name = "openkal-windows" @@ -6770,8 +6660,8 @@ version = "0.8.0" provides = ["mcpp:kernel-abi=openkal"] )"; auto m = mcpp::manifest::parse_string(kernelAbiPkg); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->cEnvironment, "platform") + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->cEnvironment, "platform")) << "a kernel-abi provider must default to the platform boundary " "without having to declare c-environment itself"; @@ -6781,8 +6671,8 @@ name = "some-library" version = "1.0.0" )"; auto m2 = mcpp::manifest::parse_string(ordinaryPkg); - ASSERT_TRUE(m2.has_value()) << m2.error().format(); - EXPECT_TRUE(m2->cEnvironment.empty()) + if (auto&& failed = expect(fatal(m2.has_value())); !failed) failed << m2.error().format(); + expect(m2->cEnvironment.empty()) << "an ordinary package must NOT be inferred into the platform " "boundary -- only a kernel-abi provider is"; @@ -6795,9 +6685,9 @@ version = "0.15.0" provides = ["mcpp:c-abi=musl"] )"; auto m3 = mcpp::manifest::parse_string(cAbiPkg); - ASSERT_TRUE(m3.has_value()) << m3.error().format(); - EXPECT_TRUE(m3->cEnvironment.empty()); -} + if (auto&& failed = expect(fatal(m3.has_value())); !failed) failed << m3.error().format(); + expect(m3->cEnvironment.empty()); +}}; // The same inference, through the OTHER manifest parser: an xpkg descriptor // (index packages -- exactly how a released kernel-abi implementation like @@ -6805,7 +6695,7 @@ provides = ["mcpp:c-abi=musl"] // at all (docs/22's own documented gap), so the inference here is // unconditional rather than "if empty" -- and this is the test that pins // that it still runs. -TEST(SynthesizeFromXpkgLua, CEnvironmentIsInferredForAKernelAbiProvider) { +UTest synthesize_from_xpkg_lua_c_environment_is_inferred_for_a_kernel_abi_provider{"SynthesizeFromXpkgLua/CEnvironmentIsInferredForAKernelAbiProvider", [] { constexpr auto lua = R"( package = { spec = "1", @@ -6820,9 +6710,9 @@ package = { )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua( lua, "openkal-windows", "0.8.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->cEnvironment, "platform"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->cEnvironment, "platform")); +}}; // ── #728, D7: matching conditional tables apply in order of specificity ────── // @@ -6831,22 +6721,22 @@ package = { // breaks a tie. The triple below sorts BEFORE `linux` and `cfg(unix)`, so under // the lexical order these tables used to apply in, the family table was the // last word on an aarch64 Linux target. -TEST(ConditionalOrder, SpecificityRanksATripleOverAnOsOverAFamily) { +UTest specificity_ranks_a_triple_over_an_os_over_a_family{"ConditionalOrder/SpecificityRanksATripleOverAnOsOverAFamily", [] { using mcpp::manifest::cfg::specificity; - EXPECT_EQ(specificity("x86_64-unknown-linux-gnu"), mcpp::manifest::cfg::kTripleRank); - EXPECT_EQ(specificity("linux"), 2); - EXPECT_EQ(specificity("cfg(os = \"linux\")"), 2); - EXPECT_EQ(specificity("unix"), 1); - EXPECT_EQ(specificity("cfg(family = \"unix\")"), 1); - EXPECT_EQ(specificity("cfg(arch = \"x86_64\")"), 1); - EXPECT_EQ(specificity("cfg(all(os = \"linux\", arch = \"aarch64\"))"), 3); - EXPECT_EQ(specificity("cfg(any(linux, windows))"), 0); - EXPECT_EQ(specificity("cfg(not(windows))"), 0); - EXPECT_EQ(specificity("cfg(c-abi = \"musl\")"), 0); - EXPECT_GT(specificity("aarch64-unknown-linux-gnu"), specificity("cfg(all(os = \"linux\", arch = \"aarch64\"))")); -} - -TEST(ConditionalOrder, AMoreSpecificTableAppliesLater) { + expect(eq(specificity("x86_64-unknown-linux-gnu"), mcpp::manifest::cfg::kTripleRank)); + expect(eq(specificity("linux"), 2)); + expect(eq(specificity("cfg(os = \"linux\")"), 2)); + expect(eq(specificity("unix"), 1)); + expect(eq(specificity("cfg(family = \"unix\")"), 1)); + expect(eq(specificity("cfg(arch = \"x86_64\")"), 1)); + expect(eq(specificity("cfg(all(os = \"linux\", arch = \"aarch64\"))"), 3)); + expect(eq(specificity("cfg(any(linux, windows))"), 0)); + expect(eq(specificity("cfg(not(windows))"), 0)); + expect(eq(specificity("cfg(c-abi = \"musl\")"), 0)); + expect(gt(specificity("aarch64-unknown-linux-gnu"), specificity("cfg(all(os = \"linux\", arch = \"aarch64\"))"))); +}}; + +UTest a_more_specific_table_applies_later{"ConditionalOrder/AMoreSpecificTableAppliesLater", [] { constexpr auto src = R"( [package] name = "x" @@ -6865,55 +6755,55 @@ cxxflags = ["-DFAMILY"] threads = false )"; auto parsed = mcpp::manifest::parse_string(src); - ASSERT_TRUE(parsed.has_value()) << parsed.error().format(); + if (auto&& failed = expect(fatal(parsed.has_value())); !failed) failed << parsed.error().format(); std::vector order; for (auto const& cc : parsed->conditionalConfigs) order.push_back(cc.predicate); // The two `cfg(unix)` rows (build, abi) are one table; ties keep text order. - ASSERT_GE(order.size(), 3u); - EXPECT_EQ(order.front(), "cfg(unix)"); - EXPECT_EQ(order.back(), "aarch64-unknown-linux-gnu"); + expect(fatal(ge(order.size(), 3u))); + expect(eq(order.front(), "cfg(unix)")); + expect(eq(order.back(), "aarch64-unknown-linux-gnu")); auto m = manifest_dialect::merged_for(src, "aarch64-unknown-linux-gnu"); const auto& f = m.buildConfig.cxxflags; - ASSERT_EQ(f.size(), 4u); - EXPECT_EQ(f[0], "-DBASE"); - EXPECT_EQ(f[1], "-DFAMILY"); - EXPECT_EQ(f[2], "-DOS"); - EXPECT_EQ(f[3], "-DTRIPLE"); + expect(fatal(eq(f.size(), 4u))); + expect(eq(f[0], "-DBASE")); + expect(eq(f[1], "-DFAMILY")); + expect(eq(f[2], "-DOS")); + expect(eq(f[3], "-DTRIPLE")); // The scalar: the triple states it last, so the triple's value stands. - EXPECT_TRUE(m.buildConfig.abiThreads); -} + expect(bool(m.buildConfig.abiThreads)); +}}; // A tie is broken by the selector text, so the order is the same on every // reader and every run. -TEST(ConditionalOrder, EqualSpecificityKeepsTheSelectorTextOrder) { +UTest equal_specificity_keeps_the_selector_text_order{"ConditionalOrder/EqualSpecificityKeepsTheSelectorTextOrder", [] { std::vector tables(3); tables[0].predicate = "cfg(unix)"; tables[1].predicate = "cfg(arch = \"x86_64\")"; tables[2].predicate = "cfg(env = \"gnu\")"; mcpp::manifest::cfg::order_by_specificity(tables); - EXPECT_EQ(tables[0].predicate, "cfg(arch = \"x86_64\")"); - EXPECT_EQ(tables[1].predicate, "cfg(env = \"gnu\")"); - EXPECT_EQ(tables[2].predicate, "cfg(unix)"); -} + expect(eq(tables[0].predicate, "cfg(arch = \"x86_64\")")); + expect(eq(tables[1].predicate, "cfg(env = \"gnu\")")); + expect(eq(tables[2].predicate, "cfg(unix)")); +}}; // `[test] windows_code_page` (2026.10.5.2): the code page of every discovered // test program, in the closed vocabulary of the target key. -TEST(Manifest, TestWindowsCodePageIsAClosedSetOfTwo) { +UTest test_windows_code_page_is_a_closed_set_of_two{"Manifest/TestWindowsCodePageIsAClosedSetOfTwo", [] { auto ok = mcpp::manifest::parse_string( "[package]\nname = \"p\"\nversion = \"0.1.0\"\n[test]\nwindows_code_page = \"utf-8\"\n"); - ASSERT_TRUE(ok.has_value()) << ok.error().format(); - EXPECT_EQ(ok->testWindowsCodePage, "utf-8"); - EXPECT_TRUE(ok->schemaWarnings.empty()); + if (auto&& failed = expect(fatal(ok.has_value())); !failed) failed << ok.error().format(); + expect(eq(ok->testWindowsCodePage, "utf-8")); + expect(ok->schemaWarnings.empty()); auto bad = mcpp::manifest::parse_string( "[package]\nname = \"p\"\nversion = \"0.1.0\"\n[test]\nwindows_code_page = \"936\"\n"); - ASSERT_FALSE(bad.has_value()); - EXPECT_NE(bad.error().format().find("windows_code_page"), std::string::npos); -} + expect(fatal(!bad.has_value())); + expect(ne(bad.error().format().find("windows_code_page"), std::string::npos)); +}}; // A Form B descriptor states its interface root with `lib`, below the // archive's wrapping directory as its `sources` are (SPEC-008 v0.2, mcpp#797). -TEST(SynthesizeFromXpkgLua, LibStatesTheInterfaceRootBelowTheWrappingDirectory) { +UTest lib_states_the_interface_root_below_the_wrapping_directory{"SynthesizeFromXpkgLua/LibStatesTheInterfaceRootBelowTheWrappingDirectory", [] { constexpr auto src = R"( package = { spec = "1", @@ -6929,23 +6819,22 @@ package = { )"; auto m = mcpp::manifest::synthesize_from_xpkg_lua(src, "tomlpp", "3.4.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->lib.path.generic_string(), "*/modules/tomlplusplus.cppm"); - EXPECT_TRUE(m->xpkgUnknownKeys.empty()); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->lib.path.generic_string(), "*/modules/tomlplusplus.cppm")); + expect(m->xpkgUnknownKeys.empty()); const auto root = std::filesystem::temp_directory_path() / std::format("mcpp-lib-root-{}", std::random_device{}()); std::filesystem::create_directories(root / "tomlplusplus-3.4.0" / "modules"); std::ofstream(root / "tomlplusplus-3.4.0" / "modules" / "tomlplusplus.cppm") << "export module tomlpp;\n"; - EXPECT_EQ(mcpp::manifest::resolve_lib_root_path(*m, root).generic_string(), - "tomlplusplus-3.4.0/modules/tomlplusplus.cppm"); + expect(eq(mcpp::manifest::resolve_lib_root_path(*m, root).generic_string(), "tomlplusplus-3.4.0/modules/tomlplusplus.cppm")); std::error_code ec; std::filesystem::remove_all(root, ec); -} +}}; // Design 2026-10-26 §24.3: an entry target, a program that begins in a // dependency's entry, and the symbol an image is entered at. -TEST(Manifest, ParsesEntryTargetsAndAProgramThatBeginsInOne) { +UTest parses_entry_targets_and_a_program_that_begins_in_one{"Manifest/ParsesEntryTargetsAndAProgramThatBeginsInOne", [] { constexpr auto src = R"( [package] name = "fw" @@ -6963,29 +6852,28 @@ main = { from = "openmui/player" } start = "_start" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_EQ(m->targets.size(), 2u); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(eq(m->targets.size(), 2u))); auto const& entry = m->targets[0].name == "player" ? m->targets[0] : m->targets[1]; auto const& notes = m->targets[0].name == "notes" ? m->targets[0] : m->targets[1]; - EXPECT_EQ(entry.kind, mcpp::manifest::Target::Entry); - EXPECT_FALSE(entry.is_program()); - EXPECT_EQ(entry.main, "src/entry/player.cpp"); - EXPECT_EQ(entry.start, "_reset"); - EXPECT_EQ(entry.windowsEntry, "WinMain"); - EXPECT_EQ(notes.main, ""); - EXPECT_EQ(notes.mainFrom, "openmui/player"); - EXPECT_EQ(notes.start, "_start"); - EXPECT_EQ(notes.startDeclaredBy, "[targets.notes] start = \"_start\""); - EXPECT_TRUE(m->schemaWarnings.empty()); -} - -TEST(Manifest, RefusesWhatAnEntryOrABeginningCannotState) { + expect(eq(entry.kind, mcpp::manifest::Target::Entry)); + expect(!entry.is_program()); + expect(eq(entry.main, "src/entry/player.cpp")); + expect(eq(entry.start, "_reset")); + expect(eq(entry.windowsEntry, "WinMain")); + expect(eq(notes.main, "")); + expect(eq(notes.mainFrom, "openmui/player")); + expect(eq(notes.start, "_start")); + expect(eq(notes.startDeclaredBy, "[targets.notes] start = \"_start\"")); + expect(m->schemaWarnings.empty()); +}}; + +UTest refuses_what_an_entry_or_a_beginning_cannot_state{"Manifest/RefusesWhatAnEntryOrABeginningCannotState", [] { auto refused = [](std::string_view body, std::string_view needle) { auto m = mcpp::manifest::parse_string( std::format("[package]\nname = \"p\"\nversion = \"0.1.0\"\n{}", body)); - ASSERT_FALSE(m.has_value()) << body; - EXPECT_NE(m.error().format().find(needle), std::string::npos) - << body << "\n" << m.error().format(); + expect(fatal(!m.has_value())) << body; + if (auto&& failed = expect(ne(m.error().format().find(needle), std::string::npos)); !failed) failed << body << "\n" << m.error().format(); }; refused("[targets.e]\nkind = \"entry\"\nmain = \"e.cpp\"\nsoname = \"libe.so\"\n", "produces no artifact of its own"); @@ -7003,9 +6891,9 @@ TEST(Manifest, RefusesWhatAnEntryOrABeginningCannotState) { "applies to the target's own entry translation unit"); refused("[test]\nmain = \"t.cpp\"\n", "[test] main is `{ from"); refused("[test]\nstart = \"9\"\n", "[test] start"); -} +}}; -TEST(Manifest, ParsesTestMainFromAndStart) { +UTest parses_test_main_from_and_start{"Manifest/ParsesTestMainFromAndStart", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "p" @@ -7014,14 +6902,14 @@ version = "0.1.0" main = { from = "compat.gtest/main" } start = "_reset" )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->testMainFrom, "compat.gtest/main"); - EXPECT_EQ(m->testStart, "_reset"); -} + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->testMainFrom, "compat.gtest/main")); + expect(eq(m->testStart, "_reset")); +}}; // A row states a program's or an entry's beginning, and only of such a target; // every file a target names as `main` on any row is recorded on it. -TEST(Manifest, ARowStatesABeginningOfAProgramOrAnEntry) { +UTest a_row_states_a_beginning_of_a_program_or_an_entry{"Manifest/ARowStatesABeginningOfAProgramOrAnEntry", [] { auto tmp = std::filesystem::temp_directory_path() / "mcpp_row_beginning"; std::filesystem::create_directories(tmp); auto path = tmp / "mcpp.toml"; @@ -7040,15 +6928,15 @@ main = "src/entry/player_android.cpp" start = "_reset" )"); auto m = mcpp::manifest::load(path); - ASSERT_TRUE(m) << (m ? "" : m.error().message); + if (auto&& failed = expect(fatal(bool(m))); !failed) failed << (m ? "" : m.error().message); auto it = std::ranges::find_if(m->targets, [](auto const& t) { return t.name == "player"; }); - ASSERT_NE(it, m->targets.end()); - EXPECT_EQ(it->otherMains, (std::vector{"src/entry/player_android.cpp"})); - ASSERT_EQ(m->conditionalConfigs.size(), 1u); + expect(fatal(ne(it, m->targets.end()))); + expect(eq(it->otherMains, (std::vector{"src/entry/player_android.cpp"}))); + expect(fatal(eq(m->conditionalConfigs.size(), 1u))); auto const& row = m->conditionalConfigs[0].targetKinds.at("player"); - EXPECT_FALSE(row.statesForm); - EXPECT_EQ(row.main, std::optional("src/entry/player_android.cpp")); - EXPECT_EQ(row.start, std::optional("_reset")); + expect(!row.statesForm); + expect(eq(row.main, std::optional("src/entry/player_android.cpp"))); + expect(eq(row.start, std::optional("_reset"))); write(R"( [targets.p] @@ -7057,8 +6945,8 @@ kind = "lib" main = "x.cpp" )"); auto lib = mcpp::manifest::load(path); - ASSERT_FALSE(lib); - EXPECT_NE(lib.error().message.find("is a library target"), std::string::npos) << lib.error().message; + expect(fatal(!lib)); + if (auto&& failed = expect(ne(lib.error().message.find("is a library target"), std::string::npos)); !failed) failed << lib.error().message; write(R"( [targets.p] @@ -7068,16 +6956,15 @@ kind = "shared" main = "x.cpp" )"); auto both = mcpp::manifest::load(path); - ASSERT_FALSE(both); - EXPECT_NE(both.error().message.find("a target is one or the other"), std::string::npos) - << both.error().message; + expect(fatal(!both)); + if (auto&& failed = expect(ne(both.error().message.find("a target is one or the other"), std::string::npos)); !failed) failed << both.error().message; std::error_code ec; std::filesystem::remove_all(tmp, ec); -} +}}; // A package that declares only entries still has the library its sources // infer: an entry is no artifact of its own package. -TEST(Manifest, OnlyEntryTargetsStillInferTheLibrary) { +UTest only_entry_targets_still_infer_the_library{"Manifest/OnlyEntryTargetsStillInferTheLibrary", [] { auto tmp = std::filesystem::temp_directory_path() / "mcpp_entry_infer"; std::filesystem::create_directories(tmp / "src" / "entry"); { std::ofstream(tmp / "src" / "fw.cppm") << "export module fw;\n"; } @@ -7087,17 +6974,17 @@ TEST(Manifest, OnlyEntryTargetsStillInferTheLibrary) { "[targets.player]\nkind = \"entry\"\nmain = \"src/entry/player.cpp\"\n"; } auto m = mcpp::manifest::load(tmp / "mcpp.toml"); - ASSERT_TRUE(m) << (m ? "" : m.error().message); - EXPECT_TRUE(std::ranges::any_of(m->targets, [](auto const& t) { + if (auto&& failed = expect(fatal(bool(m))); !failed) failed << (m ? "" : m.error().message); + expect(std::ranges::any_of(m->targets, [](auto const& t) { return t.name == "fw" && t.kind == mcpp::manifest::Target::Library; })); std::error_code ec; std::filesystem::remove_all(tmp, ec); -} +}}; // ── [pack] targets and [build] target (SPEC-015 §3.3) ──────────────────────── -TEST(Manifest, PackTargetsTakeTargetsAndSets) { +UTest pack_targets_take_targets_and_sets{"Manifest/PackTargetsTakeTargetsAndSets", [] { auto list = mcpp::manifest::parse_string(R"( [package] name = "p" @@ -7105,8 +6992,8 @@ version = "0.1.0" [pack] targets = ["openkal", "x86_64-linux-musl"] )"); - ASSERT_TRUE(list.has_value()) << list.error().format(); - EXPECT_EQ(list->packConfig.targets, (std::vector{"openkal", "x86_64-linux-musl"})); + if (auto&& failed = expect(fatal(list.has_value())); !failed) failed << list.error().format(); + expect(eq(list->packConfig.targets, (std::vector{"openkal", "x86_64-linux-musl"}))); auto one = mcpp::manifest::parse_string(R"( [package] name = "p" @@ -7114,13 +7001,13 @@ version = "0.1.0" [pack] targets = "linux-musl" )"); - ASSERT_TRUE(one.has_value()) << one.error().format(); - EXPECT_EQ(one->packConfig.targets, (std::vector{"linux-musl"})); -} + if (auto&& failed = expect(fatal(one.has_value())); !failed) failed << one.error().format(); + expect(eq(one->packConfig.targets, (std::vector{"linux-musl"}))); +}}; // `[pack] format` and `[pack] systems` are a string or a list, and // `default_mode` takes the canonical names (design 2026-10-29 §4.1, §4.6). -TEST(Manifest, PackFormatAndSystemsAreAStringOrAList) { +UTest pack_format_and_systems_are_a_string_or_a_list{"Manifest/PackFormatAndSystemsAreAStringOrAList", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "p" @@ -7130,10 +7017,10 @@ default_mode = "self-contained" format = ["tar", "okb"] systems = "linux-aarch64" )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->packConfig.defaultMode, "self-contained"); - EXPECT_EQ(m->packConfig.format, (std::vector{"tar", "okb"})); - EXPECT_EQ(m->packConfig.systems, (std::vector{"linux-aarch64"})); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->packConfig.defaultMode, "self-contained")); + expect(eq(m->packConfig.format, (std::vector{"tar", "okb"}))); + expect(eq(m->packConfig.systems, (std::vector{"linux-aarch64"}))); auto one = mcpp::manifest::parse_string(R"( [package] name = "p" @@ -7141,11 +7028,11 @@ version = "0.1.0" [pack] format = "zip" )"); - ASSERT_TRUE(one.has_value()) << one.error().format(); - EXPECT_EQ(one->packConfig.format, (std::vector{"zip"})); -} + if (auto&& failed = expect(fatal(one.has_value())); !failed) failed << one.error().format(); + expect(eq(one->packConfig.format, (std::vector{"zip"}))); +}}; -TEST(Manifest, PackTargetsRefuseWhatIsNeitherATargetNorASet) { +UTest pack_targets_refuse_what_is_neither_a_target_nor_a_set{"Manifest/PackTargetsRefuseWhatIsNeitherATargetNorASet", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "p" @@ -7153,11 +7040,11 @@ version = "0.1.0" [pack] targets = ["not a target"] )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().format().find("neither a target"), std::string::npos); -} + expect(fatal(!m.has_value())); + expect(ne(m.error().format().find("neither a target"), std::string::npos)); +}}; -TEST(Manifest, BuildTargetIsOneTarget) { +UTest build_target_is_one_target{"Manifest/BuildTargetIsOneTarget", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "p" @@ -7165,8 +7052,8 @@ version = "0.1.0" [build] target = "openkal" )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().format().find("names a target set"), std::string::npos); + expect(fatal(!m.has_value())); + expect(ne(m.error().format().find("names a target set"), std::string::npos)); auto ok = mcpp::manifest::parse_string(R"( [package] name = "p" @@ -7174,5 +7061,5 @@ version = "0.1.0" [build] target = "x86_64-openkal" )"); - ASSERT_TRUE(ok.has_value()) << ok.error().format(); -} + if (auto&& failed = expect(fatal(ok.has_value())); !failed) failed << ok.error().format(); +}}; diff --git a/tests/unit/test_member_selection.cpp b/tests/unit/test_member_selection.cpp index 0cecc1ffb..d8e3708a3 100644 --- a/tests/unit/test_member_selection.cpp +++ b/tests/unit/test_member_selection.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.cli.selection; +using namespace mcpplibs::mtest; + // Member selection design 2026-09-30, S1 to S3. `select_members` is the one // function every command that acts on workspace members reads its `-p`, // `--workspace` and `--exclude` through, so the set a command plans does not @@ -53,7 +55,7 @@ struct Workspace { } mcpp::manifest::Manifest load() { auto m = mcpp::manifest::load(root / "mcpp.toml"); - EXPECT_TRUE(m.has_value()) << (m ? "" : m.error().format()); + if (auto&& failed = expect(m.has_value()); !failed) failed << (m ? "" : m.error().format()); return m ? std::move(*m) : mcpp::manifest::Manifest{}; } }; @@ -78,56 +80,56 @@ struct Three : Workspace { // S1: a virtual root without `-p` is the whole workspace, `--workspace` is // the whole workspace anywhere, and both are the same set. -TEST(MemberSelection, AVirtualRootWithoutDashPAndWorkspaceAreEveryMember) { +UTest a_virtual_root_without_dash_p_and_workspace_are_every_member{"MemberSelection/AVirtualRootWithoutDashPAndWorkspaceAreEveryMember", [] { Three f; auto ws = f.ws(); auto implicit = mcpp::cli::select_members(ws, f.root, "", request(false)); - ASSERT_TRUE(implicit.has_value()) << implicit.error(); - EXPECT_EQ(implicit->members, (Members{"a", "b", "c"})); - EXPECT_TRUE(implicit->whole); + if (auto&& failed = expect(fatal(implicit.has_value())); !failed) failed << implicit.error(); + expect(eq(implicit->members, (Members{"a", "b", "c"}))); + expect(bool(implicit->whole)); auto all = mcpp::cli::select_members(ws, f.root, "b", request(true)); - ASSERT_TRUE(all.has_value()) << all.error(); - EXPECT_EQ(all->members, (Members{"a", "b", "c"})); - EXPECT_TRUE(all->whole); -} + if (auto&& failed = expect(fatal(all.has_value())); !failed) failed << all.error(); + expect(eq(all->members, (Members{"a", "b", "c"}))); + expect(bool(all->whole)); +}}; // A rooted workspace's own package is "." and comes first. Without a selector // it is the only member; `--workspace` adds the others. -TEST(MemberSelection, ARootedRootSelectsItsOwnPackageFirst) { +UTest a_rooted_root_selects_its_own_package_first{"MemberSelection/ARootedRootSelectsItsOwnPackageFirst", [] { Workspace f("rooted"); f.member("a", "a"); f.member("b", "b"); auto ws = f.rooted_root("\"a\", \"b\""); auto bare = mcpp::cli::select_members(ws, f.root, "", request(false)); - ASSERT_TRUE(bare.has_value()) << bare.error(); - EXPECT_EQ(bare->members, (Members{"."})); - EXPECT_FALSE(bare->whole); + if (auto&& failed = expect(fatal(bare.has_value())); !failed) failed << bare.error(); + expect(eq(bare->members, (Members{"."}))); + expect(!bare->whole); auto all = mcpp::cli::select_members(ws, f.root, "", request(true)); - ASSERT_TRUE(all.has_value()) << all.error(); - EXPECT_EQ(all->members, (Members{".", "a", "b"})); + if (auto&& failed = expect(fatal(all.has_value())); !failed) failed << all.error(); + expect(eq(all->members, (Members{".", "a", "b"}))); auto named = mcpp::cli::select_members(ws, f.root, "", request(false, {"b", "rootpkg"})); - ASSERT_TRUE(named.has_value()) << named.error(); - EXPECT_EQ(named->members, (Members{".", "b"})); -} + if (auto&& failed = expect(fatal(named.has_value())); !failed) failed << named.error(); + expect(eq(named->members, (Members{".", "b"}))); +}}; // A command run inside a member's directory selects that member. -TEST(MemberSelection, InsideAMemberSelectsThatMember) { +UTest inside_a_member_selects_that_member{"MemberSelection/InsideAMemberSelectsThatMember", [] { Three f; auto ws = f.ws(); auto in = mcpp::cli::select_members(ws, f.root, "b", request(false)); - ASSERT_TRUE(in.has_value()) << in.error(); - EXPECT_EQ(in->members, (Members{"b"})); - EXPECT_FALSE(in->whole); -} + if (auto&& failed = expect(fatal(in.has_value())); !failed) failed << in.error(); + expect(eq(in->members, (Members{"b"}))); + expect(!in->whole); +}}; // S1: `-p` names members, each resolved in the order docs/07 §5.3 states. The // selection is a set kept in manifest order: the order `-p` was written in // does not change it, and a member named twice, by two spellings, is one. -TEST(MemberSelection, SeveralDashPSelectTheirMembersOnceInManifestOrder) { +UTest several_dash_p_select_their_members_once_in_manifest_order{"MemberSelection/SeveralDashPSelectTheirMembersOnceInManifestOrder", [] { Workspace f("set"); f.member("libs/a", "alpha"); f.member("libs/b", "beta"); @@ -136,23 +138,23 @@ TEST(MemberSelection, SeveralDashPSelectTheirMembersOnceInManifestOrder) { auto ab = mcpp::cli::select_members(ws, f.root, "", request(false, {"alpha", "beta"})); auto ba = mcpp::cli::select_members(ws, f.root, "", request(false, {"beta", "alpha"})); - ASSERT_TRUE(ab.has_value()) << ab.error(); - ASSERT_TRUE(ba.has_value()) << ba.error(); - EXPECT_EQ(ab->members, (Members{"libs/a", "libs/b"})); - EXPECT_EQ(ab->members, ba->members); - EXPECT_FALSE(ab->whole); + if (auto&& failed = expect(fatal(ab.has_value())); !failed) failed << ab.error(); + if (auto&& failed = expect(fatal(ba.has_value())); !failed) failed << ba.error(); + expect(eq(ab->members, (Members{"libs/a", "libs/b"}))); + expect(eq(ab->members, ba->members)); + expect(!ab->whole); // The package name, the member path and the directory's last segment are // three spellings of one member. auto same = mcpp::cli::select_members(ws, f.root, "", request(false, {"alpha", "libs/a", "a", "./libs/a"})); - ASSERT_TRUE(same.has_value()) << same.error(); - EXPECT_EQ(same->members, (Members{"libs/a"})); -} + if (auto&& failed = expect(fatal(same.has_value())); !failed) failed << same.error(); + expect(eq(same->members, (Members{"libs/a"}))); +}}; // A `-p` that names no member is refused, and the refusal names it and lists // the members; one that several members match is refused naming every match. -TEST(MemberSelection, AnUnknownOrAmbiguousDashPIsRefusedByName) { +UTest an_unknown_or_ambiguous_dash_p_is_refused_by_name{"MemberSelection/AnUnknownOrAmbiguousDashPIsRefusedByName", [] { Workspace f("refuse"); f.member("a", "same", "ns1"); f.member("b", "same", "ns2"); @@ -160,125 +162,124 @@ TEST(MemberSelection, AnUnknownOrAmbiguousDashPIsRefusedByName) { auto ws = f.virtual_root("\"a\", \"b\", \"c\""); auto unknown = mcpp::cli::select_members(ws, f.root, "", request(false, {"c", "nosuch"})); - ASSERT_FALSE(unknown.has_value()); - EXPECT_NE(unknown.error().find("nosuch"), std::string::npos) << unknown.error(); - EXPECT_NE(unknown.error().find("'c'"), std::string::npos) << unknown.error(); + expect(fatal(!unknown.has_value())); + if (auto&& failed = expect(ne(unknown.error().find("nosuch"), std::string::npos)); !failed) failed << unknown.error(); + if (auto&& failed = expect(ne(unknown.error().find("'c'"), std::string::npos)); !failed) failed << unknown.error(); auto ambiguous = mcpp::cli::select_members(ws, f.root, "", request(false, {"same"})); - ASSERT_FALSE(ambiguous.has_value()); - EXPECT_NE(ambiguous.error().find("ns1.same"), std::string::npos) << ambiguous.error(); - EXPECT_NE(ambiguous.error().find("ns2.same"), std::string::npos) << ambiguous.error(); + expect(fatal(!ambiguous.has_value())); + if (auto&& failed = expect(ne(ambiguous.error().find("ns1.same"), std::string::npos)); !failed) failed << ambiguous.error(); + if (auto&& failed = expect(ne(ambiguous.error().find("ns2.same"), std::string::npos)); !failed) failed << ambiguous.error(); auto qualified = mcpp::cli::select_members(ws, f.root, "", request(false, {"ns2.same", "ns1.same"})); - ASSERT_TRUE(qualified.has_value()) << qualified.error(); - EXPECT_EQ(qualified->members, (Members{"a", "b"})); -} + if (auto&& failed = expect(fatal(qualified.has_value())); !failed) failed << qualified.error(); + expect(eq(qualified->members, (Members{"a", "b"}))); +}}; // S3: `--exclude` removes members from either "all" form, by the same // resolution as `-p`, and keeps manifest order. -TEST(MemberSelection, ExcludeRemovesMembersFromAnAllForm) { +UTest exclude_removes_members_from_an_all_form{"MemberSelection/ExcludeRemovesMembersFromAnAllForm", [] { Three f; auto ws = f.ws(); auto flag = mcpp::cli::select_members(ws, f.root, "", request(true, {}, {"c"})); - ASSERT_TRUE(flag.has_value()) << flag.error(); - EXPECT_EQ(flag->members, (Members{"a", "b"})); - EXPECT_TRUE(flag->whole); + if (auto&& failed = expect(fatal(flag.has_value())); !failed) failed << flag.error(); + expect(eq(flag->members, (Members{"a", "b"}))); + expect(bool(flag->whole)); // The implicit whole selection of a virtual root, and two spellings. auto implicit = mcpp::cli::select_members(ws, f.root, "", request(false, {}, {"a", "./c"})); - ASSERT_TRUE(implicit.has_value()) << implicit.error(); - EXPECT_EQ(implicit->members, (Members{"b"})); + if (auto&& failed = expect(fatal(implicit.has_value())); !failed) failed << implicit.error(); + expect(eq(implicit->members, (Members{"b"}))); Workspace r("rooted-exclude"); r.member("a", "a"); auto rws = r.rooted_root("\"a\""); auto root = mcpp::cli::select_members(rws, r.root, "", request(true, {}, {"rootpkg"})); - ASSERT_TRUE(root.has_value()) << root.error(); - EXPECT_EQ(root->members, (Members{"a"})); -} + if (auto&& failed = expect(fatal(root.has_value())); !failed) failed << root.error(); + expect(eq(root->members, (Members{"a"}))); +}}; // S1, refusals: before anything is planned, `--exclude` with `-p`, `--exclude` // that names no member, `--exclude` that leaves nothing, and `--exclude` // where no "all" form applies. -TEST(MemberSelection, ExcludeIsRefusedWhereItHasNoMeaning) { +UTest exclude_is_refused_where_it_has_no_meaning{"MemberSelection/ExcludeIsRefusedWhereItHasNoMeaning", [] { Three f; auto ws = f.ws(); auto withP = mcpp::cli::select_members(ws, f.root, "", request(false, {"a"}, {"b"})); - ASSERT_FALSE(withP.has_value()); - EXPECT_NE(withP.error().find("--exclude"), std::string::npos) << withP.error(); - EXPECT_NE(withP.error().find("-p"), std::string::npos) << withP.error(); + expect(fatal(!withP.has_value())); + if (auto&& failed = expect(ne(withP.error().find("--exclude"), std::string::npos)); !failed) failed << withP.error(); + if (auto&& failed = expect(ne(withP.error().find("-p"), std::string::npos)); !failed) failed << withP.error(); auto withPAndAll = mcpp::cli::select_members(ws, f.root, "", request(true, {"a"}, {"b"})); - EXPECT_FALSE(withPAndAll.has_value()); + expect(!withPAndAll.has_value()); auto unknown = mcpp::cli::select_members(ws, f.root, "", request(true, {}, {"nosuch"})); - ASSERT_FALSE(unknown.has_value()); - EXPECT_NE(unknown.error().find("nosuch"), std::string::npos) << unknown.error(); + expect(fatal(!unknown.has_value())); + if (auto&& failed = expect(ne(unknown.error().find("nosuch"), std::string::npos)); !failed) failed << unknown.error(); auto everything = mcpp::cli::select_members(ws, f.root, "", request(true, {}, {"a", "b", "c"})); - ASSERT_FALSE(everything.has_value()); - EXPECT_NE(everything.error().find("every member"), std::string::npos) << everything.error(); + expect(fatal(!everything.has_value())); + if (auto&& failed = expect(ne(everything.error().find("every member"), std::string::npos)); !failed) failed << everything.error(); // Inside a member, and at a rooted root, without `--workspace`: nothing is // a whole-workspace selection to remove from. auto inside = mcpp::cli::select_members(ws, f.root, "a", request(false, {}, {"b"})); - ASSERT_FALSE(inside.has_value()); - EXPECT_NE(inside.error().find("--workspace"), std::string::npos) << inside.error(); + expect(fatal(!inside.has_value())); + if (auto&& failed = expect(ne(inside.error().find("--workspace"), std::string::npos)); !failed) failed << inside.error(); Workspace r("rooted-refuse"); r.member("a", "a"); auto rws = r.rooted_root("\"a\""); - EXPECT_FALSE(mcpp::cli::select_members(rws, r.root, "", request(false, {}, {"a"})).has_value()); -} + expect(!(mcpp::cli::select_members(rws, r.root, "", request(false, {}, {"a"})).has_value())); +}}; // `--workspace` and `-p` state two selections; neither is taken over the other. -TEST(MemberSelection, WorkspaceTogetherWithDashPIsRefused) { +UTest workspace_together_with_dash_p_is_refused{"MemberSelection/WorkspaceTogetherWithDashPIsRefused", [] { Three f; auto ws = f.ws(); auto both = mcpp::cli::select_members(ws, f.root, "", request(true, {"a"}, {})); - ASSERT_FALSE(both.has_value()); - EXPECT_NE(both.error().find("--workspace"), std::string::npos) << both.error(); - EXPECT_NE(both.error().find("-p"), std::string::npos) << both.error(); -} + expect(fatal(!both.has_value())); + if (auto&& failed = expect(ne(both.error().find("--workspace"), std::string::npos)); !failed) failed << both.error(); + if (auto&& failed = expect(ne(both.error().find("-p"), std::string::npos)); !failed) failed << both.error(); +}}; // The overload that takes a directory finds the workspace the directory // belongs to: from a member's directory, `-p` and `--workspace` still select // among the members of the workspace that lists it. Outside a workspace there // is no selection, and `--exclude` has nothing to name. -TEST(MemberSelection, TheDirectoryDecidesTheWorkspaceAndTheMemberItIsIn) { +UTest the_directory_decides_the_workspace_and_the_member_it_is_in{"MemberSelection/TheDirectoryDecidesTheWorkspaceAndTheMemberItIsIn", [] { Three f; f.ws(); auto at_root = mcpp::cli::select_members(request(false), f.root); - ASSERT_TRUE(at_root.has_value()) << at_root.error(); - ASSERT_TRUE(at_root->has_value()); - EXPECT_EQ((*at_root)->members, (Members{"a", "b", "c"})); + if (auto&& failed = expect(fatal(at_root.has_value())); !failed) failed << at_root.error(); + expect(fatal(at_root->has_value())); + expect(eq((*at_root)->members, (Members{"a", "b", "c"}))); auto in_member = mcpp::cli::select_members(request(false), f.root / "b"); - ASSERT_TRUE(in_member.has_value()) << in_member.error(); - ASSERT_TRUE(in_member->has_value()); - EXPECT_EQ((*in_member)->members, (Members{"b"})); - EXPECT_EQ(std::filesystem::weakly_canonical((*in_member)->root), - std::filesystem::weakly_canonical(f.root)); + if (auto&& failed = expect(fatal(in_member.has_value())); !failed) failed << in_member.error(); + expect(fatal(in_member->has_value())); + expect(eq((*in_member)->members, (Members{"b"}))); + expect(eq(std::filesystem::weakly_canonical((*in_member)->root), std::filesystem::weakly_canonical(f.root))); auto several_from_member = mcpp::cli::select_members(request(false, {"a", "c"}), f.root / "b"); - ASSERT_TRUE(several_from_member.has_value()) << several_from_member.error(); - ASSERT_TRUE(several_from_member->has_value()); - EXPECT_EQ((*several_from_member)->members, (Members{"a", "c"})); + if (auto&& failed = expect(fatal(several_from_member.has_value())); !failed) failed << several_from_member.error(); + expect(fatal(several_from_member->has_value())); + expect(eq((*several_from_member)->members, (Members{"a", "c"}))); auto all_from_member = mcpp::cli::select_members(request(true, {}, {"a"}), f.root / "b"); - ASSERT_TRUE(all_from_member.has_value()) << all_from_member.error(); - ASSERT_TRUE(all_from_member->has_value()); - EXPECT_EQ((*all_from_member)->members, (Members{"b", "c"})); + if (auto&& failed = expect(fatal(all_from_member.has_value())); !failed) failed << all_from_member.error(); + expect(fatal(all_from_member->has_value())); + expect(eq((*all_from_member)->members, (Members{"b", "c"}))); Workspace alone("alone"); alone.member(".", "solo"); auto none = mcpp::cli::select_members(request(false), alone.root); - ASSERT_TRUE(none.has_value()) << none.error(); - EXPECT_FALSE(none->has_value()); + if (auto&& failed = expect(fatal(none.has_value())); !failed) failed << none.error(); + expect(!none->has_value()); auto excluded = mcpp::cli::select_members(request(false, {}, {"x"}), alone.root); - ASSERT_FALSE(excluded.has_value()); - EXPECT_NE(excluded.error().find("not in one"), std::string::npos) << excluded.error(); -} + expect(fatal(!excluded.has_value())); + if (auto&& failed = expect(ne(excluded.error().find("not in one"), std::string::npos)); !failed) failed << excluded.error(); +}}; diff --git a/tests/unit/test_modgraph.cpp b/tests/unit/test_modgraph.cpp index 5469e99bb..f66008feb 100644 --- a/tests/unit/test_modgraph.cpp +++ b/tests/unit/test_modgraph.cpp @@ -1,6 +1,6 @@ -#include import std; +import mcpplibs.mtest; import mcpp.modgraph.glob; import mcpp.modgraph.graph; import mcpp.modgraph.scanner; @@ -8,6 +8,8 @@ import mcpp.modgraph.validate; import mcpp.manifest; import mcpp.source_kind; +using namespace mcpplibs::mtest; + using namespace mcpp::modgraph; namespace { @@ -27,7 +29,7 @@ void write(const std::filesystem::path& p, std::string_view content) { } // namespace -TEST(Scanner, ProvidesAndRequires) { +UTest provides_and_requires{"Scanner/ProvidesAndRequires", [] { auto dir = make_tempdir("mcpp-scanner"); // NOTE: avoid raw string literal for module source — clang-scan-deps // on Windows may false-positive on `import bar;` inside R"(...)". @@ -38,15 +40,15 @@ TEST(Scanner, ProvidesAndRequires) { "export int answer();\n"); auto u = scan_file(dir / "src" / "foo.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()) << u.error().format(); - ASSERT_TRUE(u->provides.has_value()); - EXPECT_EQ(u->provides->logicalName, "foo"); - ASSERT_EQ(u->requires_.size(), 2u); - EXPECT_EQ(u->requires_[0].logicalName, "std"); - EXPECT_EQ(u->requires_[1].logicalName, "bar"); + if (auto&& failed = expect(fatal(u.has_value())); !failed) failed << u.error().format(); + expect(fatal(u->provides.has_value())); + expect(eq(u->provides->logicalName, "foo")); + expect(fatal(eq(u->requires_.size(), 2u))); + expect(eq(u->requires_[0].logicalName, "std")); + expect(eq(u->requires_[1].logicalName, "bar")); std::filesystem::remove_all(dir); -} +}}; // Regression: `import` lines that live INSIDE a multi-line raw-string literal // (e.g. a `mcpp new --template gui` skeleton embedded as R"GUI( ... )GUI") must @@ -62,7 +64,7 @@ TEST(Scanner, ProvidesAndRequires) { // also warned "imported but not provided in this build" on every platform, // which read like a note instead of the cause. -TEST(Scanner, ImplementationPartitionProvidesItsPartitionName) { +UTest implementation_partition_provides_its_partition_name{"Scanner/ImplementationPartitionProvidesItsPartitionName", [] { auto dir = make_tempdir("scan-implpart"); std::filesystem::create_directories(dir / "src"); write(dir / "src" / "secret.cppm", @@ -71,39 +73,39 @@ TEST(Scanner, ImplementationPartitionProvidesItsPartitionName) { auto u = scan_file(dir / "src" / "secret.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()); - ASSERT_TRUE(u->provides.has_value()); - EXPECT_EQ(u->provides->logicalName, "mathkit:secret"); + expect(fatal(u.has_value())); + expect(fatal(u->provides.has_value())); + expect(eq(u->provides->logicalName, "mathkit:secret")); // Not an interface: its source must not be published by `mcpp pack`. // Compared against the value, not tested for truthiness — the field is a // tri-state now (nullopt means "nobody determined this"), and on an // optional both EXPECT_FALSE and EXPECT_TRUE ask about the wrong thing. - EXPECT_EQ(u->providesInterface, std::optional{false}); + expect(eq(u->providesInterface, std::optional{false})); // And it must not require its own name, which is what it used to do. for (auto const& r : u->requires_) - EXPECT_NE(r.logicalName, "mathkit:secret"); + expect(ne(r.logicalName, "mathkit:secret")); std::filesystem::remove_all(dir); -} +}}; -TEST(Scanner, InterfacePartitionIsMarkedAsAnInterface) { +UTest interface_partition_is_marked_as_an_interface{"Scanner/InterfacePartitionIsMarkedAsAnInterface", [] { auto dir = make_tempdir("scan-ifacepart"); std::filesystem::create_directories(dir / "src"); write(dir / "src" / "api.cppm", "export module mathkit:api;\nexport int f();\n"); auto u = scan_file(dir / "src" / "api.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()); - ASSERT_TRUE(u->provides.has_value()); - EXPECT_EQ(u->provides->logicalName, "mathkit:api"); - EXPECT_EQ(u->providesInterface, std::optional{true}); + expect(fatal(u.has_value())); + expect(fatal(u->provides.has_value())); + expect(eq(u->provides->logicalName, "mathkit:api")); + expect(eq(u->providesInterface, std::optional{true})); std::filesystem::remove_all(dir); -} +}}; // ─── the declaration form (build database roles) ───────────────────────── // // `module M;` is stored as `requires M`, exactly as `import M;` is, so the form // cannot be recovered from provides/requires. The scanner records what it read. -TEST(Scanner, DeclarationFormIsRecordedAsRead) { +UTest declaration_form_is_recorded_as_read{"Scanner/DeclarationFormIsRecordedAsRead", [] { auto dir = make_tempdir("scan-declform"); std::filesystem::create_directories(dir / "src"); struct Case { const char* file; const char* text; ModuleDeclaration want; }; @@ -122,11 +124,11 @@ TEST(Scanner, DeclarationFormIsRecordedAsRead) { for (auto const& c : cases) { write(dir / "src" / c.file, c.text); auto u = scan_file(dir / "src" / c.file, "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()) << c.file; - EXPECT_EQ(static_cast(u->declaration), static_cast(c.want)) << c.file; + expect(fatal(u.has_value())) << c.file; + expect(eq(static_cast(u->declaration), static_cast(c.want))) << c.file; } std::filesystem::remove_all(dir); -} +}}; // ─── an entry source no `sources` glob matched ───────────────────────────── // @@ -134,7 +136,7 @@ TEST(Scanner, DeclarationFormIsRecordedAsRead) { // Its imports were read from line-leading `import` alone, so an import inside a // comment or a raw string was recorded as one: a scanner test in lsp-mcpp was // planned as importing three modules no source provides. -TEST(Scanner, AnEntrySourceIsReadByTheScanner) { +UTest an_entry_source_is_read_by_the_scanner{"Scanner/AnEntrySourceIsReadByTheScanner", [] { auto dir = make_tempdir("scan-entry"); write(dir / "t.cpp", "import std;\n" @@ -148,14 +150,14 @@ TEST(Scanner, AnEntrySourceIsReadByTheScanner) { auto u = scan_entry_file(dir / "t.cpp", "pkg", mcpp::builtin_extension_table()); std::vector names; for (auto const& r : u.requires_) names.push_back(r.logicalName); - EXPECT_EQ(names, (std::vector{"std", "real"})); - EXPECT_EQ(static_cast(u.declaration), static_cast(ModuleDeclaration::None)); + expect(eq(names, (std::vector{"std", "real"}))); + expect(eq(static_cast(u.declaration), static_cast(ModuleDeclaration::None))); std::filesystem::remove_all(dir); -} +}}; // The scanner's refusals were never applied to such a file, and are not now: a // file it refuses keeps its line-leading imports, and its form is not decided. -TEST(Scanner, AnEntrySourceTheScannerRefusesKeepsItsImports) { +UTest an_entry_source_the_scanner_refuses_keeps_its_imports{"Scanner/AnEntrySourceTheScannerRefusesKeepsItsImports", [] { auto dir = make_tempdir("scan-entry-refused"); write(dir / "t.cpp", "#ifdef WITH_EXTRA\n" @@ -163,30 +165,30 @@ TEST(Scanner, AnEntrySourceTheScannerRefusesKeepsItsImports) { "#endif\n" "export import std;\n" "int main() {}\n"); - ASSERT_FALSE(scan_file(dir / "t.cpp", "pkg", mcpp::builtin_extension_table()).has_value()); + expect(fatal(!(scan_file(dir / "t.cpp", "pkg", mcpp::builtin_extension_table()).has_value()))); auto u = scan_entry_file(dir / "t.cpp", "pkg", mcpp::builtin_extension_table()); std::vector names; for (auto const& r : u.requires_) names.push_back(r.logicalName); - EXPECT_EQ(names, (std::vector{"extra", "std"})); - EXPECT_EQ(static_cast(u.declaration), static_cast(ModuleDeclaration::Unknown)); + expect(eq(names, (std::vector{"extra", "std"}))); + expect(eq(static_cast(u.declaration), static_cast(ModuleDeclaration::Unknown))); std::filesystem::remove_all(dir); -} +}}; -TEST(Scanner, PlainImplementationUnitStillRequiresItsInterface) { +UTest plain_implementation_unit_still_requires_its_interface{"Scanner/PlainImplementationUnitStillRequiresItsInterface", [] { auto dir = make_tempdir("scan-implunit"); std::filesystem::create_directories(dir / "src"); write(dir / "src" / "impl.cpp", "module mathkit;\nnamespace mk { int g() { return 2; } }\n"); auto u = scan_file(dir / "src" / "impl.cpp", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()); - EXPECT_FALSE(u->provides.has_value()); - ASSERT_EQ(u->requires_.size(), 1u); - EXPECT_EQ(u->requires_[0].logicalName, "mathkit"); + expect(fatal(u.has_value())); + expect(!(u->provides.has_value())); + expect(fatal(eq(u->requires_.size(), 1u))); + expect(eq(u->requires_[0].logicalName, "mathkit")); std::filesystem::remove_all(dir); -} +}}; -TEST(Scanner, PartitionImportResolvesInsideAnImplementationUnit) { +UTest partition_import_resolves_inside_an_implementation_unit{"Scanner/PartitionImportResolvesInsideAnImplementationUnit", [] { // The other half of the same bug: resolution keyed off `u.provides`, and an // implementation unit has none — so `import :secret;` stayed the literal // `:secret`, which nothing provides. @@ -197,17 +199,17 @@ TEST(Scanner, PartitionImportResolvesInsideAnImplementationUnit) { auto u = scan_file(dir / "src" / "impl.cpp", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()); + expect(fatal(u.has_value())); bool found = false; for (auto const& r : u->requires_) { - EXPECT_NE(r.logicalName, ":secret"); + expect(ne(r.logicalName, ":secret")); if (r.logicalName == "mathkit:secret") found = true; } - EXPECT_TRUE(found) << "`import :secret;` did not resolve to mathkit:secret"; + expect(bool(found)) << "`import :secret;` did not resolve to mathkit:secret"; std::filesystem::remove_all(dir); -} +}}; -TEST(Scanner, PartitionImportResolvesInsideAPartition) { +UTest partition_import_resolves_inside_a_partition{"Scanner/PartitionImportResolvesInsideAPartition", [] { // `export module foo:http;` + `import :tls;` must give `foo:tls`, not // `foo:http:tls` — the case the old code was written for, still true. auto dir = make_tempdir("scan-partpart"); @@ -216,13 +218,13 @@ TEST(Scanner, PartitionImportResolvesInsideAPartition) { auto u = scan_file(dir / "src" / "http.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()); - ASSERT_EQ(u->requires_.size(), 1u); - EXPECT_EQ(u->requires_[0].logicalName, "foo:tls"); + expect(fatal(u.has_value())); + expect(fatal(eq(u->requires_.size(), 1u))); + expect(eq(u->requires_[0].logicalName, "foo:tls")); std::filesystem::remove_all(dir); -} +}}; -TEST(Scanner, IgnoresImportsInsideRawStringLiteral) { +UTest ignores_imports_inside_raw_string_literal{"Scanner/IgnoresImportsInsideRawStringLiteral", [] { auto dir = make_tempdir("mcpp-scanner-raw"); write(dir / "src" / "gen.cppm", "export module gen;\n" @@ -236,23 +238,23 @@ TEST(Scanner, IgnoresImportsInsideRawStringLiteral) { "export void f();\n"); auto u = scan_file(dir / "src" / "gen.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()) << u.error().format(); - ASSERT_TRUE(u->provides.has_value()); - EXPECT_EQ(u->provides->logicalName, "gen"); + if (auto&& failed = expect(fatal(u.has_value())); !failed) failed << u.error().format(); + expect(fatal(u->provides.has_value())); + expect(eq(u->provides->logicalName, "gen")); // Only the two genuine top-level imports — NOT imgui.core / imgui.app. - ASSERT_EQ(u->requires_.size(), 2u); - EXPECT_EQ(u->requires_[0].logicalName, "std"); - EXPECT_EQ(u->requires_[1].logicalName, "bar"); + expect(fatal(eq(u->requires_.size(), 2u))); + expect(eq(u->requires_[0].logicalName, "std")); + expect(eq(u->requires_[1].logicalName, "bar")); for (auto& r : u->requires_) { - EXPECT_NE(r.logicalName, "imgui.core"); - EXPECT_NE(r.logicalName, "imgui.app"); + expect(ne(r.logicalName, "imgui.core")); + expect(ne(r.logicalName, "imgui.app")); } std::filesystem::remove_all(dir); -} +}}; // A single-line raw string with an embedded import-looking body stays code. -TEST(Scanner, IgnoresImportInsideSingleLineRawString) { +UTest ignores_import_inside_single_line_raw_string{"Scanner/IgnoresImportInsideSingleLineRawString", [] { auto dir = make_tempdir("mcpp-scanner-raw1"); write(dir / "src" / "one.cppm", "export module one;\n" @@ -260,17 +262,17 @@ TEST(Scanner, IgnoresImportInsideSingleLineRawString) { "import real;\n"); auto u = scan_file(dir / "src" / "one.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()) << u.error().format(); - ASSERT_EQ(u->requires_.size(), 1u); - EXPECT_EQ(u->requires_[0].logicalName, "real"); + if (auto&& failed = expect(fatal(u.has_value())); !failed) failed << u.error().format(); + expect(fatal(eq(u->requires_.size(), 1u))); + expect(eq(u->requires_[0].logicalName, "real")); std::filesystem::remove_all(dir); -} +}}; // Assembly units skip the module text scan entirely (like .c): an .asm/.S // file legally contains no `import`/`module` declarations, and asm comment // syntax would misparse. -TEST(Scanner, AssemblySourcesSkipModuleScan) { +UTest assembly_sources_skip_module_scan{"Scanner/AssemblySourcesSkipModuleScan", [] { auto dir = make_tempdir("mcpp-scanner-asm"); write(dir / "src" / "simd.asm", "; import std -- a comment, not a declaration\n" @@ -283,34 +285,34 @@ TEST(Scanner, AssemblySourcesSkipModuleScan) { for (auto name : { "simd.asm", "copy.S" }) { auto u = scan_file(dir / "src" / name, "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()) << u.error().format(); - EXPECT_FALSE(u->provides.has_value()) << name; - EXPECT_TRUE(u->requires_.empty()) << name; + if (auto&& failed = expect(fatal(u.has_value())); !failed) failed << u.error().format(); + expect(!(u->provides.has_value())) << name; + expect(u->requires_.empty()) << name; } std::filesystem::remove_all(dir); -} +}}; // G8a: source globs follow directory symlinks (vendored trees are often // symlink farms) without looping on cycles. -TEST(Scanner, ExpandGlobFollowsDirectorySymlinks) { +UTest expand_glob_follows_directory_symlinks{"Scanner/ExpandGlobFollowsDirectorySymlinks", [] { auto dir = make_tempdir("mcpp-scanner-symlink"); auto real = dir / "vendor-real"; write(real / "impl.cpp", "int f() { return 1; }\n"); std::error_code ec; std::filesystem::create_directories(dir / "src"); std::filesystem::create_directory_symlink(real, dir / "src" / "vendor", ec); - if (ec) GTEST_SKIP() << "symlinks unsupported here: " << ec.message(); + if (ec) skip([&] { std::ostringstream text; text << "symlinks unsupported here: " << ec.message(); return text.str(); }()); // A cycle: the linked tree points back at its parent. std::filesystem::create_directory_symlink(dir, real / "loop", ec); auto files = expand_glob(dir, "src/**/*.cpp"); - ASSERT_EQ(files.size(), 1u); - EXPECT_EQ(files[0].filename(), "impl.cpp"); + expect(fatal(eq(files.size(), 1u))); + expect(eq(files[0].filename(), "impl.cpp")); std::filesystem::remove_all(dir); -} +}}; // mcpp#225 review finding: glob_literal_prefix's contract (the piece that // actually decides WHERE expand_glob/expand_dir_glob start walking) is the @@ -318,20 +320,20 @@ TEST(Scanner, ExpandGlobFollowsDirectorySymlinks) { // directly — rather than inferring its behavior from a filesystem walk whose // entry order is unspecified by the standard — makes this test's pass/fail // independent of directory-enumeration order on any filesystem. -TEST(Scanner, GlobLiteralPrefixDerivation) { - EXPECT_EQ(glob_literal_prefix("src/**/*.cppm"), "src"); +UTest glob_literal_prefix_derivation{"Scanner/GlobLiteralPrefixDerivation", [] { + expect(eq(glob_literal_prefix("src/**/*.cppm"), "src")); // Wildcard already in the first segment: no literal directory to bound to. - EXPECT_EQ(glob_literal_prefix("**/*.c"), ""); + expect(eq(glob_literal_prefix("**/*.c"), "")); // No wildcard at all: the full parent directory path is the prefix. - EXPECT_EQ(glob_literal_prefix("a/b/c.cpp").generic_string(), "a/b"); + expect(eq(glob_literal_prefix("a/b/c.cpp").generic_string(), "a/b")); // Truncate back to the last COMPLETE '/' before the first wildcard char — // "x*.cpp" is a partial segment, not a real directory named "x". - EXPECT_EQ(glob_literal_prefix("src/x*.cpp"), "src"); + expect(eq(glob_literal_prefix("src/x*.cpp"), "src")); // '{' is treated as a segment-boundary wildcard char (brace-expansion // globs are desugared before reaching this helper — see its comment in // scanner.cppm), so the prefix truncates at the last '/' before it. - EXPECT_EQ(glob_literal_prefix("a/{x,y}/z"), "a"); -} + expect(eq(glob_literal_prefix("a/{x,y}/z"), "a")); +}}; // MSVC's std::filesystem::path preserves the separators of the string it was // constructed from, so a raw `a/b` prefix stays generic and `root / p` turns @@ -340,64 +342,60 @@ TEST(Scanner, GlobLiteralPrefixDerivation) { // glob_literal_prefix must return NATIVE separators so the walk and everything // downstream is native too. (The generic spelling is already locked down by // Scanner.GlobLiteralPrefixDerivation above.) -TEST(Scanner, GlobLiteralPrefixUsesNativeSeparators) { +UTest glob_literal_prefix_uses_native_separators{"Scanner/GlobLiteralPrefixUsesNativeSeparators", [] { if constexpr (std::filesystem::path::preferred_separator == '\\') { - EXPECT_EQ(glob_literal_prefix("a/b/c.cpp").string(), "a\\b"); - EXPECT_EQ(glob_literal_prefix("a/b/c.cpp").string().find('/'), - std::string::npos); + expect(eq(glob_literal_prefix("a/b/c.cpp").string(), "a\\b")); + expect(eq(glob_literal_prefix("a/b/c.cpp").string().find('/'), std::string::npos)); } -} +}}; // The exported converter itself — both spelling directions. -TEST(Glob, NativePathFromGeneric) { +UTest glob_native_path_from_generic{"Glob/NativePathFromGeneric", [] { auto p = mcpp::modgraph::native_path_from_generic("a/b/c"); - EXPECT_EQ(p.generic_string(), "a/b/c"); + expect(eq(p.generic_string(), "a/b/c")); if constexpr (std::filesystem::path::preferred_separator == '\\') { - EXPECT_EQ(p.string(), "a\\b\\c"); + expect(eq(p.string(), "a\\b\\c")); // Already-native input is untouched. - EXPECT_EQ(mcpp::modgraph::native_path_from_generic("C:\\x\\y").string(), - "C:\\x\\y"); + expect(eq(mcpp::modgraph::native_path_from_generic("C:\\x\\y").string(), "C:\\x\\y")); } -} +}}; // The end-to-end shape of the reported bug: a source under a multi-segment // glob (`generated/modules/**/*.cppm`) must come out of expand_glob with // NATIVE separators on Windows — the mixed `root\generated/modules\a.cppm` // was what compile_commands.json's `file` field showed before the fix. -TEST(Scanner, ExpandGlobMultiSegmentPrefixUsesNativeSeparators) { +UTest expand_glob_multi_segment_prefix_uses_native_separators{"Scanner/ExpandGlobMultiSegmentPrefixUsesNativeSeparators", [] { auto dir = make_tempdir("mcpp-scanner-multi"); write(dir / "generated" / "modules" / "a.cppm", "export module a;\n"); auto files = expand_glob(dir, "generated/modules/**/*.cppm"); - ASSERT_EQ(files.size(), 1u); + expect(fatal(eq(files.size(), 1u))); if constexpr (std::filesystem::path::preferred_separator == '\\') { - EXPECT_EQ(files[0].string().find('/'), std::string::npos) << files[0]; + if (auto&& failed = expect(eq(files[0].string().find('/'), std::string::npos)); !failed) failed << files[0]; } - EXPECT_EQ(files[0].generic_string(), - (dir / "generated" / "modules" / "a.cppm").generic_string()); + expect(eq(files[0].generic_string(), (dir / "generated" / "modules" / "a.cppm").generic_string())); std::filesystem::remove_all(dir); -} +}}; // Same contract for the INCLUDE-DIR channel (expand_dir_glob): a multi-segment // `third_party/inc` entry must yield a native path or the CDB's -I carries the // mixed form. -TEST(Scanner, ExpandDirGlobMultiSegmentUsesNativeSeparators) { +UTest expand_dir_glob_multi_segment_uses_native_separators{"Scanner/ExpandDirGlobMultiSegmentUsesNativeSeparators", [] { auto dir = make_tempdir("mcpp-scanner-dirglob"); std::filesystem::create_directories(dir / "third_party" / "inc"); auto dirs = expand_dir_glob(dir, "third_party/inc"); - ASSERT_EQ(dirs.size(), 1u); + expect(fatal(eq(dirs.size(), 1u))); if constexpr (std::filesystem::path::preferred_separator == '\\') { - EXPECT_EQ(dirs[0].string().find('/'), std::string::npos) << dirs[0]; + if (auto&& failed = expect(eq(dirs[0].string().find('/'), std::string::npos)); !failed) failed << dirs[0]; } - EXPECT_EQ(dirs[0].generic_string(), - (dir / "third_party" / "inc").generic_string()); + expect(eq(dirs[0].generic_string(), (dir / "third_party" / "inc").generic_string())); std::filesystem::remove_all(dir); -} +}}; // mcpp#225: expand_glob must bound its walk to the glob's literal directory // prefix ("src" for "src/**/*.cppm") instead of always walking the whole @@ -408,18 +406,18 @@ TEST(Scanner, ExpandDirGlobMultiSegmentUsesNativeSeparators) { // deterministically by Scanner.GlobLiteralPrefixDerivation above, which // tests glob_literal_prefix directly and isn't subject to filesystem // enumeration order. -TEST(Scanner, ExpandGlobStartsAtLiteralPrefix) { +UTest expand_glob_starts_at_literal_prefix{"Scanner/ExpandGlobStartsAtLiteralPrefix", [] { auto dir = make_tempdir("mcpp-scanner-prefix"); write(dir / "other" / "b.cppm", "export module b;\n"); write(dir / "src" / "a.cppm", "export module a;\n"); auto files = expand_glob(dir, "src/**/*.cppm"); - ASSERT_EQ(files.size(), 1u); - EXPECT_EQ(files[0], dir / "src" / "a.cppm"); + expect(fatal(eq(files.size(), 1u))); + expect(eq(files[0], dir / "src" / "a.cppm")); std::filesystem::remove_all(dir); -} +}}; // Supplementary (not the primary regression guard — see // Scanner.GlobLiteralPrefixDerivation and Scanner.ExpandGlobStartsAtLiteralPrefix @@ -431,7 +429,7 @@ TEST(Scanner, ExpandGlobStartsAtLiteralPrefix) { // landmine didn't fire (order-dependent), the test still asserts the // positive result and reports (via trace) that the landmine was inert this // run, instead of pretending it verified the old-code-fails claim. -TEST(Scanner, ExpandGlobStartsAtLiteralPrefixLandmine) { +UTest expand_glob_starts_at_literal_prefix_landmine{"Scanner/ExpandGlobStartsAtLiteralPrefixLandmine", [] { auto dir = make_tempdir("mcpp-scanner-prefix-landmine"); auto blocked = dir / "junk" / "blocked"; std::filesystem::create_directories(blocked); @@ -448,7 +446,7 @@ TEST(Scanner, ExpandGlobStartsAtLiteralPrefixLandmine) { if (!probeEc) { std::filesystem::permissions(blocked, std::filesystem::perms::all, permEc); std::filesystem::remove_all(dir); - GTEST_SKIP() << "cannot restrict directory permissions in this environment"; + skip("cannot restrict directory permissions in this environment"); } auto files = expand_glob(dir, "src/**/*.cppm"); @@ -465,17 +463,17 @@ TEST(Scanner, ExpandGlobStartsAtLiteralPrefixLandmine) { // just isn't, by itself, an order-independent proof that old code would // have failed here (that proof now lives in // Scanner.GlobLiteralPrefixDerivation instead). - ASSERT_EQ(files.size(), 1u); - EXPECT_EQ(files[0], dir / "src" / "a.cppm"); + expect(fatal(eq(files.size(), 1u))); + expect(eq(files[0], dir / "src" / "a.cppm")); std::filesystem::remove_all(dir); -} +}}; // mcpp#225: the walk must prune VCS metadata (.git) and mcpp's own build // output (target) the same way it already prunes .mcpp (mcpp#230). Glob has // no literal prefix ("**/*.cppm") so the walk starts at root and would // otherwise reach all three excluded trees. -TEST(Scanner, ExcludesGitAndTargetAndMcpp) { +UTest excludes_git_and_target_and_mcpp{"Scanner/ExcludesGitAndTargetAndMcpp", [] { auto dir = make_tempdir("mcpp-scanner-exclude"); write(dir / ".git" / "x.cppm", "export module x;\n"); write(dir / "target" / "y.cppm", "export module y;\n"); @@ -484,13 +482,13 @@ TEST(Scanner, ExcludesGitAndTargetAndMcpp) { auto files = expand_glob(dir, "**/*.cppm"); - ASSERT_EQ(files.size(), 1u); - EXPECT_EQ(files[0], dir / "src" / "ok.cppm"); + expect(fatal(eq(files.size(), 1u))); + expect(eq(files[0], dir / "src" / "ok.cppm")); std::filesystem::remove_all(dir); -} +}}; -TEST(Scanner, RecordsPackageLocalIncludeDirs) { +UTest records_package_local_include_dirs{"Scanner/RecordsPackageLocalIncludeDirs", [] { auto dir = make_tempdir("mcpp-scanner-includes"); write(dir / "src" / "foo.cpp", "int answer() { return 42; }\n"); @@ -503,18 +501,18 @@ TEST(Scanner, RecordsPackageLocalIncludeDirs) { m.buildConfig.includeDirs = {"include", "private/*"}; auto r = scan_packages({PackageRoot{dir, m}}); - ASSERT_TRUE(r.errors.empty()); - ASSERT_EQ(r.graph.units.size(), 1u); + expect(fatal(r.errors.empty())); + expect(fatal(eq(r.graph.units.size(), 1u))); auto const& dirs = r.graph.units[0].localIncludeDirs; - ASSERT_EQ(dirs.size(), 2u); - EXPECT_EQ(dirs[0], dir / "include"); - EXPECT_EQ(dirs[1], dir / "private" / "nested"); + expect(fatal(eq(dirs.size(), 2u))); + expect(eq(dirs[0], dir / "include")); + expect(eq(dirs[1], dir / "private" / "nested")); std::filesystem::remove_all(dir); -} +}}; -TEST(Scanner, UsesResolvedPackagePrivateBuildIncludeDirs) { +UTest uses_resolved_package_private_build_include_dirs{"Scanner/UsesResolvedPackagePrivateBuildIncludeDirs", [] { auto dir = make_tempdir("mcpp-scanner-resolved-includes"); write(dir / "src" / "foo.cpp", "int answer() { return 42; }\n"); @@ -531,15 +529,15 @@ TEST(Scanner, UsesResolvedPackagePrivateBuildIncludeDirs) { p.privateBuild.includeDirs = {dir / "resolved"}; auto r = scan_packages({p}); - ASSERT_TRUE(r.errors.empty()); - ASSERT_EQ(r.graph.units.size(), 1u); + expect(fatal(r.errors.empty())); + expect(fatal(eq(r.graph.units.size(), 1u))); auto const& dirs = r.graph.units[0].localIncludeDirs; - ASSERT_EQ(dirs.size(), 1u); - EXPECT_EQ(dirs[0], dir / "resolved"); + expect(fatal(eq(dirs.size(), 1u))); + expect(eq(dirs[0], dir / "resolved")); std::filesystem::remove_all(dir); -} +}}; // The realised [c-abi] environment (design 2026-09-18) broadcasts into // `privateBuild.asmflags`, and this proves the wiring on the ONE point that @@ -551,7 +549,7 @@ TEST(Scanner, UsesResolvedPackagePrivateBuildIncludeDirs) { // is not a degraded build, it is a build that does not assemble at all, so // this is a refusal-shaped guarantee even though nothing here calls // `refusal::record`: the wrong list for the wrong assembler. -TEST(Scanner, ResolvedCEnvAsmflagsReachGasUnitsOnlyNotNasm) { +UTest resolved_c_env_asmflags_reach_gas_units_only_not_nasm{"Scanner/ResolvedCEnvAsmflagsReachGasUnitsOnlyNotNasm", [] { auto dir = make_tempdir("mcpp-scanner-cenv-asm"); write(dir / "src" / "gas.S", ".text\n.globl gas_marker\ngas_marker:\n ret\n"); @@ -567,33 +565,31 @@ TEST(Scanner, ResolvedCEnvAsmflagsReachGasUnitsOnlyNotNasm) { p.privateBuild.asmflags = {"--target=x86_64-pc-cygwin", "-fno-short-wchar"}; auto r = scan_packages({p}); - ASSERT_TRUE(r.errors.empty()); - ASSERT_EQ(r.graph.units.size(), 2u); + expect(fatal(r.errors.empty())); + expect(fatal(eq(r.graph.units.size(), 2u))); bool sawGas = false, sawNasm = false; for (auto const& u : r.graph.units) { if (u.kind == mcpp::SourceKind::GasAsm) { sawGas = true; - EXPECT_NE(std::find(u.packageAsmflags.begin(), u.packageAsmflags.end(), - "--target=x86_64-pc-cygwin"), - u.packageAsmflags.end()) + expect(ne(std::find(u.packageAsmflags.begin(), u.packageAsmflags.end(), + "--target=x86_64-pc-cygwin"), u.packageAsmflags.end())) << "the realised environment must reach a GAS unit"; } else if (u.kind == mcpp::SourceKind::NasmAsm) { sawNasm = true; - EXPECT_EQ(std::find(u.packageAsmflags.begin(), u.packageAsmflags.end(), - "--target=x86_64-pc-cygwin"), - u.packageAsmflags.end()) + expect(eq(std::find(u.packageAsmflags.begin(), u.packageAsmflags.end(), + "--target=x86_64-pc-cygwin"), u.packageAsmflags.end())) << "NASM does not understand a clang --target= flag and must " "never receive it"; } } - EXPECT_TRUE(sawGas); - EXPECT_TRUE(sawNasm); + expect(bool(sawGas)); + expect(bool(sawNasm)); std::filesystem::remove_all(dir); -} +}}; -TEST(Scanner, PartitionImportFromPrimaryInterface) { +UTest partition_import_from_primary_interface{"Scanner/PartitionImportFromPrimaryInterface", [] { // Primary module interface: `export module foo;` → logicalName = "foo". // `import :tls;` resolves to "foo:tls". auto dir = make_tempdir("mcpp-scanner"); @@ -601,13 +597,13 @@ TEST(Scanner, PartitionImportFromPrimaryInterface) { "export module foo;\n" "import :tls;\n"); auto u = scan_file(dir / "src" / "foo.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()) << u.error().format(); - ASSERT_EQ(u->requires_.size(), 1u); - EXPECT_EQ(u->requires_[0].logicalName, "foo:tls"); + if (auto&& failed = expect(fatal(u.has_value())); !failed) failed << u.error().format(); + expect(fatal(eq(u->requires_.size(), 1u))); + expect(eq(u->requires_[0].logicalName, "foo:tls")); std::filesystem::remove_all(dir); -} +}}; -TEST(Scanner, PartitionImportFromAnotherPartition) { +UTest partition_import_from_another_partition{"Scanner/PartitionImportFromAnotherPartition", [] { // Partition interface: `export module foo:http;` → logicalName = "foo:http". // `import :tls;` must resolve to "foo:tls" (the sibling partition), // NOT "foo:http:tls" (which is what a naive prepend produces). @@ -617,16 +613,16 @@ TEST(Scanner, PartitionImportFromAnotherPartition) { "import :tls;\n" "import :socket;\n"); auto u = scan_file(dir / "src" / "http.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()) << u.error().format(); - ASSERT_TRUE(u->provides.has_value()); - EXPECT_EQ(u->provides->logicalName, "foo:http"); - ASSERT_EQ(u->requires_.size(), 2u); - EXPECT_EQ(u->requires_[0].logicalName, "foo:tls"); - EXPECT_EQ(u->requires_[1].logicalName, "foo:socket"); + if (auto&& failed = expect(fatal(u.has_value())); !failed) failed << u.error().format(); + expect(fatal(u->provides.has_value())); + expect(eq(u->provides->logicalName, "foo:http")); + expect(fatal(eq(u->requires_.size(), 2u))); + expect(eq(u->requires_[0].logicalName, "foo:tls")); + expect(eq(u->requires_[1].logicalName, "foo:socket")); std::filesystem::remove_all(dir); -} +}}; -TEST(Scanner, PartitionImportWithDottedModuleName) { +UTest partition_import_with_dotted_module_name{"Scanner/PartitionImportWithDottedModuleName", [] { // Dotted module names (xpkg-style, e.g. `mcpplibs.tinyhttps:http`) // — only the colon-prefixed partition suffix is what we strip. auto dir = make_tempdir("mcpp-scanner"); @@ -634,13 +630,13 @@ TEST(Scanner, PartitionImportWithDottedModuleName) { "export module mcpplibs.tinyhttps:http;\n" "import :tls;\n"); auto u = scan_file(dir / "src" / "http.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()) << u.error().format(); - ASSERT_EQ(u->requires_.size(), 1u); - EXPECT_EQ(u->requires_[0].logicalName, "mcpplibs.tinyhttps:tls"); + if (auto&& failed = expect(fatal(u.has_value())); !failed) failed << u.error().format(); + expect(fatal(eq(u->requires_.size(), 1u))); + expect(eq(u->requires_[0].logicalName, "mcpplibs.tinyhttps:tls")); std::filesystem::remove_all(dir); -} +}}; -TEST(Scanner, RejectsConditionalImport) { +UTest rejects_conditional_import{"Scanner/RejectsConditionalImport", [] { auto dir = make_tempdir("mcpp-scanner"); write(dir / "main.cpp", "import std;\n" @@ -649,38 +645,38 @@ TEST(Scanner, RejectsConditionalImport) { "#endif\n" "int main(){}"); auto r = scan_file(dir / "main.cpp", "pkg", mcpp::builtin_extension_table()); - EXPECT_FALSE(r.has_value()); - EXPECT_NE(r.error().message.find("conditional"), std::string::npos); + expect(!r.has_value()); + expect(ne(r.error().message.find("conditional"), std::string::npos)); std::filesystem::remove_all(dir); -} +}}; -TEST(Scanner, RejectsHeaderUnit) { +UTest rejects_header_unit{"Scanner/RejectsHeaderUnit", [] { auto dir = make_tempdir("mcpp-scanner"); write(dir / "main.cpp", "import std;\n" "import \"x.h\";\n" "int main(){}"); auto r = scan_file(dir / "main.cpp", "pkg", mcpp::builtin_extension_table()); - EXPECT_FALSE(r.has_value()); - EXPECT_NE(r.error().message.find("header units"), std::string::npos); + expect(!r.has_value()); + expect(ne(r.error().message.find("header units"), std::string::npos)); std::filesystem::remove_all(dir); -} +}}; -TEST(Scanner, ObjectiveCSourceIsCLike) { +UTest objective_c_source_is_c_like{"Scanner/ObjectiveCSourceIsCLike", [] { auto dir = make_tempdir("mcpp-scanner-objc"); write(dir / "src" / "window.m", "import Cocoa;\n" "int answer(void) { return 42; }\n"); auto u = scan_file(dir / "src" / "window.m", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()) << u.error().format(); - EXPECT_FALSE(u->provides.has_value()); - EXPECT_TRUE(u->requires_.empty()); + if (auto&& failed = expect(fatal(u.has_value())); !failed) failed << u.error().format(); + expect(!(u->provides.has_value())); + expect(u->requires_.empty()); std::filesystem::remove_all(dir); -} +}}; -TEST(Validate, ModuleNameNotRequiredToMatchPackageName) { +UTest module_name_not_required_to_match_package_name{"Validate/ModuleNameNotRequiredToMatchPackageName", [] { // 0.0.10+: module name does NOT need to be prefixed by package name. // The library author decides the module naming convention. Graph g; @@ -695,10 +691,10 @@ TEST(Validate, ModuleNameNotRequiredToMatchPackageName) { m.package.name = "myorg.foo"; auto rep = validate(g, m); - EXPECT_TRUE(rep.ok()) << "module name mismatch should not be an error"; -} + expect(rep.ok()) << "module name mismatch should not be an error"; +}}; -TEST(Validate, ForbiddenTopName) { +UTest forbidden_top_name{"Validate/ForbiddenTopName", [] { Graph g; SourceUnit u; u.path = "/x/foo.cppm"; @@ -711,10 +707,10 @@ TEST(Validate, ForbiddenTopName) { m.package.name = "myorg.foo"; auto rep = validate(g, m); - EXPECT_FALSE(rep.ok()); -} + expect(!rep.ok()); +}}; -TEST(Validate, LibRootHappyPath) { +UTest lib_root_happy_path{"Validate/LibRootHappyPath", [] { // Project: lib target "tinyhttps", convention puts the lib root at // src/tinyhttps.cppm exporting `mcpplibs.tinyhttps`. Two partition // siblings sit alongside. @@ -740,12 +736,12 @@ TEST(Validate, LibRootHappyPath) { m.targets.push_back(t); auto rep = validate(g, m); // empty projectRoot → on-disk check skipped - EXPECT_TRUE(rep.ok()) << "errors:" << [&]{ + expect(rep.ok()) << "errors:" << [&]{ std::string s; for (auto& e : rep.errors) s += "\n " + e.message; return s; }(); -} +}}; -TEST(Validate, LibRootExportsPartitionIsError) { +UTest lib_root_exports_partition_is_error{"Validate/LibRootExportsPartitionIsError", [] { // Lib root file at the conventional path exports `:foo` (a partition // suffix) — must be rejected: lib root must be the primary module. Graph g; @@ -764,16 +760,16 @@ TEST(Validate, LibRootExportsPartitionIsError) { m.targets.push_back(t); auto rep = validate(g, m); - EXPECT_FALSE(rep.ok()); + expect(!rep.ok()); bool found = false; for (auto& e : rep.errors) { if (e.message.find("partition") != std::string::npos && e.message.find("primary module") != std::string::npos) { found = true; break; } } - EXPECT_TRUE(found) << "expected lib-root partition error"; -} + expect(bool(found)) << "expected lib-root partition error"; +}}; -TEST(Validate, LibRootDifferentModuleNameIsAllowed) { +UTest lib_root_different_module_name_is_allowed{"Validate/LibRootDifferentModuleNameIsAllowed", [] { // 0.0.10+: lib root module name does NOT need to match [package].name. // The library author decides the module name; the build tool auto-detects. Graph g; @@ -792,10 +788,10 @@ TEST(Validate, LibRootDifferentModuleNameIsAllowed) { m.targets.push_back(t); auto rep = validate(g, m); - EXPECT_TRUE(rep.ok()) << "module name mismatch should not be an error"; -} + expect(rep.ok()) << "module name mismatch should not be an error"; +}}; -TEST(Validate, LibRootNotEnforcedForBinaryProject) { +UTest lib_root_not_enforced_for_binary_project{"Validate/LibRootNotEnforcedForBinaryProject", [] { // Pure-binary project: no lib target → no lib-root checks. Even if a // file at src/.cppm exists exporting an unrelated module, no // error should fire. @@ -809,10 +805,10 @@ TEST(Validate, LibRootNotEnforcedForBinaryProject) { m.targets.push_back(t); auto rep = validate(g, m); - EXPECT_TRUE(rep.ok()); -} + expect(rep.ok()); +}}; -TEST(Validate, LibRootMissingFileWithExplicitPathIsError) { +UTest lib_root_missing_file_with_explicit_path_is_error{"Validate/LibRootMissingFileWithExplicitPathIsError", [] { Graph g; mcpp::manifest::Manifest m; m.package.name = "myorg.foo"; @@ -824,15 +820,15 @@ TEST(Validate, LibRootMissingFileWithExplicitPathIsError) { // Pass a non-empty projectRoot so the on-disk check is enabled. auto rep = validate(g, m, std::filesystem::current_path()); - EXPECT_FALSE(rep.ok()); + expect(!rep.ok()); bool found = false; for (auto& e : rep.errors) { if (e.message.find("does not exist") != std::string::npos) { found = true; break; } } - EXPECT_TRUE(found) << "expected explicit-path-missing error"; -} + expect(bool(found)) << "expected explicit-path-missing error"; +}}; -TEST(TopoSort, DetectsCycle) { +UTest detects_cycle{"TopoSort/DetectsCycle", [] { Graph g; g.units.resize(2); g.units[0].provides = ModuleId{"a"}; @@ -845,24 +841,24 @@ TEST(TopoSort, DetectsCycle) { g.edges.push_back({1, 0}); // b->a auto r = topo_sort(g); - EXPECT_FALSE(r.has_value()); -} + expect(!r.has_value()); +}}; -TEST(IsPublicPackage, DotMarksPublic) { - EXPECT_TRUE(is_public_package_name("myorg.foo")); - EXPECT_FALSE(is_public_package_name("foo")); -} +UTest dot_marks_public{"IsPublicPackage/DotMarksPublic", [] { + expect(is_public_package_name("myorg.foo")); + expect(!is_public_package_name("foo")); +}}; -TEST(IsForbiddenTopModule, KnownNames) { - EXPECT_TRUE(is_forbidden_top_module("core")); - EXPECT_TRUE(is_forbidden_top_module("util.x")); - EXPECT_FALSE(is_forbidden_top_module("myorg.foo")); -} +UTest known_names{"IsForbiddenTopModule/KnownNames", [] { + expect(is_forbidden_top_module("core")); + expect(is_forbidden_top_module("util.x")); + expect(!is_forbidden_top_module("myorg.foo")); +}}; // G4: per-glob flags attach to exactly the matched units, in declaration // order; a zero-hit glob warns (not errors — cfg-gated source sets may // legitimately leave a glob empty on some targets). -TEST(Scanner, PerGlobFlagsAttachToMatchedUnits) { +UTest per_glob_flags_attach_to_matched_units{"Scanner/PerGlobFlagsAttachToMatchedUnits", [] { auto dir = make_tempdir("mcpp-scanner-globflags"); write(dir / "src" / "hot.cpp", "int hot() { return 1; }\n"); write(dir / "third_party" / "noisy.cpp", "int noisy() { return 2; }\n"); @@ -878,44 +874,40 @@ TEST(Scanner, PerGlobFlagsAttachToMatchedUnits) { { .glob = "nothing/**", .cxxflags = {"-Wnever"} }); auto res = scan_package(dir, m); - ASSERT_TRUE(res.errors.empty()); - ASSERT_EQ(res.graph.units.size(), 2u); + expect(fatal(res.errors.empty())); + expect(fatal(eq(res.graph.units.size(), 2u))); for (auto& u : res.graph.units) { if (u.path.filename() == "noisy.cpp") { - EXPECT_EQ(u.packageCxxflags, (std::vector{"-w"})); + expect(eq(u.packageCxxflags, (std::vector{"-w"}))); } else { // defines land before the entry's own flag lists - EXPECT_EQ(u.packageCxxflags, - (std::vector{"-DHOT", "-mavx2"})); + expect(eq(u.packageCxxflags, (std::vector{"-DHOT", "-mavx2"}))); } } // The unmatched glob warns. bool warned = false; for (auto& w : res.warnings) if (w.message.find("nothing/**") != std::string::npos) warned = true; - EXPECT_TRUE(warned); + expect(bool(warned)); std::filesystem::remove_all(dir); -} +}}; // #228: expand_braces desugars `{a,b}` into a cartesian product of plain // globs. No braces -> passthrough; multiple/nested groups combine. -TEST(Scanner, ExpandBracesDesugarsCartesianProduct) { - EXPECT_EQ(expand_braces("a/**"), (std::vector{"a/**"})); - EXPECT_EQ(expand_braces("a/{x,y}/**"), - (std::vector{"a/x/**", "a/y/**"})); - EXPECT_EQ(expand_braces("a/{x,y}/{1,2}"), - (std::vector{"a/x/1", "a/x/2", "a/y/1", "a/y/2"})); +UTest expand_braces_desugars_cartesian_product{"Scanner/ExpandBracesDesugarsCartesianProduct", [] { + expect(eq(expand_braces("a/**"), (std::vector{"a/**"}))); + expect(eq(expand_braces("a/{x,y}/**"), (std::vector{"a/x/**", "a/y/**"}))); + expect(eq(expand_braces("a/{x,y}/{1,2}"), (std::vector{"a/x/1", "a/x/2", "a/y/1", "a/y/2"}))); // Nested group. - EXPECT_EQ(expand_braces("a/{x,{y,z}}/**"), - (std::vector{"a/x/**", "a/y/**", "a/z/**"})); -} + expect(eq(expand_braces("a/{x,{y,z}}/**"), (std::vector{"a/x/**", "a/y/**", "a/z/**"}))); +}}; // Review fix on #228: brace-nesting recursion depth is bounded (cap: 32 // levels), so a pathological manifest with deeply nested `{` cannot // stack-overflow. Beyond the cap the remainder is passed through as a // literal instead of throwing — this must simply return, not crash. -TEST(Scanner, ExpandBracesBoundsDeeplyNestedRecursion) { +UTest expand_braces_bounds_deeply_nested_recursion{"Scanner/ExpandBracesBoundsDeeplyNestedRecursion", [] { std::string glob = "a/"; constexpr int kDepth = 500; // far beyond the 32-level cap for (int i = 0; i < kDepth; ++i) glob += "{"; @@ -923,15 +915,15 @@ TEST(Scanner, ExpandBracesBoundsDeeplyNestedRecursion) { for (int i = 0; i < kDepth; ++i) glob += "}"; std::vector out; - ASSERT_NO_THROW(out = expand_braces(glob)); - EXPECT_FALSE(out.empty()); -} + expect(fatal(nothrow([&] { out = expand_braces(glob); }))); + expect(!out.empty()); +}}; // #228: expand_glob applies brace desugaring at its entry, so a glob like // "p/{aac,bsf}/**" matches files under EITHER alternative directory and // nothing else (a sibling directory outside the brace group, "opus", must // not appear). -TEST(Scanner, ExpandGlobBraceAlternation) { +UTest expand_glob_brace_alternation{"Scanner/ExpandGlobBraceAlternation", [] { auto dir = make_tempdir("mcpp-scanner-brace"); write(dir / "p" / "aac" / "x.c", "int x;\n"); write(dir / "p" / "bsf" / "y.c", "int y;\n"); @@ -939,21 +931,21 @@ TEST(Scanner, ExpandGlobBraceAlternation) { auto files = expand_glob(dir, "p/{aac,bsf}/**"); - ASSERT_EQ(files.size(), 2u); + expect(fatal(eq(files.size(), 2u))); std::set names; for (auto& f : files) names.insert(f.filename().string()); - EXPECT_TRUE(names.contains("x.c")); - EXPECT_TRUE(names.contains("y.c")); - EXPECT_FALSE(names.contains("z.c")); + expect(names.contains("x.c")); + expect(names.contains("y.c")); + expect(!names.contains("z.c")); std::filesystem::remove_all(dir); -} +}}; // #228: brace alternation in a [build].flags glob must also match — the // per-glob-flags match point (scan_one_into's apply_glob_flags) uses // path_matches_glob directly rather than expand_glob's walk, so it needs its // own desugaring wire-up. -TEST(Scanner, PerGlobFlagsMatchBraceAlternation) { +UTest per_glob_flags_match_brace_alternation{"Scanner/PerGlobFlagsMatchBraceAlternation", [] { auto dir = make_tempdir("mcpp-scanner-globflags-brace"); write(dir / "p" / "aac" / "x.cpp", "int x() { return 1; }\n"); write(dir / "p" / "bsf" / "y.cpp", "int y() { return 2; }\n"); @@ -966,24 +958,24 @@ TEST(Scanner, PerGlobFlagsMatchBraceAlternation) { { .glob = "p/{aac,bsf}/**", .defines = {"CODEC"} }); auto res = scan_package(dir, m); - ASSERT_TRUE(res.errors.empty()); - ASSERT_EQ(res.graph.units.size(), 3u); + expect(fatal(res.errors.empty())); + expect(fatal(eq(res.graph.units.size(), 3u))); for (auto& u : res.graph.units) { bool wantsDefine = u.path.filename() != "z.cpp"; bool hasDefine = std::find(u.packageCxxflags.begin(), u.packageCxxflags.end(), "-DCODEC") != u.packageCxxflags.end(); - EXPECT_EQ(hasDefine, wantsDefine) << u.path.string(); + expect(eq(hasDefine, wantsDefine)) << u.path.string(); } std::filesystem::remove_all(dir); -} +}}; // G8b: relative -I flags are root-relative in the manifest but ninja runs // with cwd = output dir — the scanner absolutizes them on every unit. // The absolute spelling is NORMALIZED to native separators (#390): MSVC's // path keeps the input `/` verbatim, and `-I/abs/path` written with forward // slashes used to survive into the CDB's arguments as a mixed path. -TEST(Scanner, RelativeIncludeFlagsAbsolutized) { +UTest relative_include_flags_absolutized{"Scanner/RelativeIncludeFlagsAbsolutized", [] { auto dir = make_tempdir("mcpp-scanner-relinc"); write(dir / "src" / "a.cpp", "int a();\n"); @@ -997,17 +989,17 @@ TEST(Scanner, RelativeIncludeFlagsAbsolutized) { mm.buildConfig.cxxflags = {"-Iinc", "-I/abs/path", "-DKEEP"}; return scan_package(dir, mm); }(); - ASSERT_EQ(res.graph.units.size(), 1u); + expect(fatal(eq(res.graph.units.size(), 1u))); auto& fl = res.graph.units[0].packageCxxflags; - EXPECT_EQ(fl[0], "-I" + (dir / "inc").string()); + expect(eq(fl[0], "-I" + (dir / "inc").string())); if constexpr (std::filesystem::path::preferred_separator == '\\') - EXPECT_EQ(fl[1], "-I\\abs\\path"); // absolute stays, but native + expect(eq(fl[1], "-I\\abs\\path")); // absolute stays, but native else - EXPECT_EQ(fl[1], "-I/abs/path"); // absolute stays - EXPECT_EQ(fl[2], "-DKEEP"); // non-include untouched + expect(eq(fl[1], "-I/abs/path")); // absolute stays + expect(eq(fl[2], "-DKEEP")); // non-include untouched std::filesystem::remove_all(dir); -} +}}; // ─── mcpp#516 / #230: names the active code page cannot spell ────────────── // @@ -1035,7 +1027,7 @@ namespace { // nearest ancestor that CAN be named — never by the offending name, which // cannot be put into a message without throwing the very exception being // reported. Platform-independent logic, so this runs everywhere. -TEST(Glob, UnnarrowablePathsDedupToTheirSpellableAncestor) { +UTest unnarrowable_paths_dedup_to_their_spellable_ancestor{"Glob/UnnarrowablePathsDedupToTheirSpellableAncestor", [] { (void)take_unnarrowable_paths(); // other tests in this binary walk too const std::filesystem::path base = "/pkg/test/www"; @@ -1044,20 +1036,18 @@ TEST(Glob, UnnarrowablePathsDedupToTheirSpellableAncestor) { note_unnarrowable_path(base / "other.txt"); auto notes = take_unnarrowable_paths(); - ASSERT_EQ(notes.size(), 2u); // ".../www/bad" and ".../www" — not three files - EXPECT_NE(std::find(notes.begin(), notes.end(), (base / "bad").generic_string()), - notes.end()); - EXPECT_NE(std::find(notes.begin(), notes.end(), base.generic_string()), - notes.end()); + expect(fatal(eq(notes.size(), 2u))); // ".../www/bad" and ".../www" — not three files + expect(ne(std::find(notes.begin(), notes.end(), (base / "bad").generic_string()), notes.end())); + expect(ne(std::find(notes.begin(), notes.end(), base.generic_string()), notes.end())); // take() clears: the next command reports afresh rather than replaying. - EXPECT_TRUE(take_unnarrowable_paths().empty()); -} + expect(take_unnarrowable_paths().empty()); +}}; // A non-ASCII directory name must not disturb the walk on ANY platform. This // one is reproducible everywhere (on Windows the UTF-8 bytes land as whatever // the ACP makes of them, which still exercises the walk). -TEST(Scanner, GlobWalkHandlesNonAsciiNames) { +UTest glob_walk_handles_non_ascii_names{"Scanner/GlobWalkHandlesNonAsciiNames", [] { auto dir = make_tempdir("mcpp-scanner-nonascii"); // U+65E5 U+672C U+8A9E in UTF-8, spelled as bytes so the test does not // depend on the source file's encoding. Split before "Dir" because a C++ @@ -1067,16 +1057,15 @@ TEST(Scanner, GlobWalkHandlesNonAsciiNames) { write(dir / "zzz_ascii" / "x.h", "#pragma once\n"); std::vector dirs; - ASSERT_NO_THROW({ dirs = expand_dir_glob(dir, "*"); }); - EXPECT_NE(std::find(dirs.begin(), dirs.end(), dir / "zzz_ascii"), dirs.end()); + expect(fatal(nothrow([&] { { dirs = expand_dir_glob(dir, "*"); }; }))); + expect(ne(std::find(dirs.begin(), dirs.end(), dir / "zzz_ascii"), dirs.end())); std::vector files; - ASSERT_NO_THROW({ files = expand_glob(dir, "**/*.h"); }); - EXPECT_NE(std::find(files.begin(), files.end(), dir / "zzz_ascii" / "x.h"), - files.end()); + expect(fatal(nothrow([&] { { files = expand_glob(dir, "**/*.h"); }; }))); + expect(ne(std::find(files.begin(), files.end(), dir / "zzz_ascii" / "x.h"), files.end())); std::filesystem::remove_all(dir); -} +}}; // mcpp#516 proper: a directory name the ACTIVE CODE PAGE cannot spell used to // throw out of `is_excluded_walk_dir`'s `.filename().string()` — the first @@ -1089,10 +1078,10 @@ TEST(Scanner, GlobWalkHandlesNonAsciiNames) { // Asserts (a) the walk does not throw, (b) it is not TRUNCATED at the bad // entry, and (c) the skip was RECORDED — silently dropping the file is the // half-fix this whole change exists to avoid. -TEST(Scanner, GlobWalkSurvivesNamesTheCodePageCannotSpell) { +UTest glob_walk_survives_names_the_code_page_cannot_spell{"Scanner/GlobWalkSurvivesNamesTheCodePageCannotSpell", [] { #ifndef _WIN32 - GTEST_SKIP() << "path::string() performs no encoding conversion off Windows; " - "mcpp#516 is not reproducible here"; + skip([&] { std::ostringstream text; text << "path::string() performs no encoding conversion off Windows; " + "mcpp#516 is not reproducible here"; return text.str(); }()); #else // Devanagari is in NO Windows ANSI code page, so this case stays live on // every non-UTF-8 ACP. The Japanese name mirrors mcpp#516's real input @@ -1110,8 +1099,8 @@ TEST(Scanner, GlobWalkSurvivesNamesTheCodePageCannotSpell) { L'D', L'i', L'r'}; if (acp_can_spell(unspellable)) { - GTEST_SKIP() << "the active code page can spell any name (UTF-8 ACP); " - "mcpp#516 is unreachable on this host"; + skip([&] { std::ostringstream text; text << "the active code page can spell any name (UTF-8 ACP); " + "mcpp#516 is unreachable on this host"; return text.str(); }()); } auto dir = make_tempdir("mcpp-scanner-acp"); @@ -1125,23 +1114,22 @@ TEST(Scanner, GlobWalkSurvivesNamesTheCodePageCannotSpell) { // The include-dir channel — mcpp#516's actual path (`include_dirs = {"*"}`, // whose literal prefix is empty, so the walk starts at the package root). std::vector dirs; - ASSERT_NO_THROW({ dirs = expand_dir_glob(dir, "*"); }); - EXPECT_NE(std::find(dirs.begin(), dirs.end(), dir / "zzz_ascii"), dirs.end()) + expect(fatal(nothrow([&] { { dirs = expand_dir_glob(dir, "*"); }; }))); + expect(ne(std::find(dirs.begin(), dirs.end(), dir / "zzz_ascii"), dirs.end())) << "the walk was truncated at the unspellable entry"; // The file channel — installedLayoutMatchesIndex's // `expand_glob(verRoot, "mcpp.toml")` walks the whole tree the same way. std::vector files; - ASSERT_NO_THROW({ files = expand_glob(dir, "**/*.h"); }); - EXPECT_NE(std::find(files.begin(), files.end(), dir / "zzz_ascii" / "x.h"), - files.end()); + expect(fatal(nothrow([&] { { files = expand_glob(dir, "**/*.h"); }; }))); + expect(ne(std::find(files.begin(), files.end(), dir / "zzz_ascii" / "x.h"), files.end())); - EXPECT_FALSE(take_unnarrowable_paths().empty()) + expect(!(take_unnarrowable_paths().empty())) << "the skipped entries were not recorded, so nothing would be reported"; std::filesystem::remove_all(dir); #endif -} +}}; // ─── #693: the UTF-8 spelling a path must have ───────────────────────────── // @@ -1151,20 +1139,20 @@ TEST(Scanner, GlobWalkSurvivesNamesTheCodePageCannotSpell) { // and recorded, exactly as the Windows test above states for a name the code // page cannot spell. -TEST(Glob, Utf8ValidityIsDecidedByteByByte) { - EXPECT_TRUE(is_valid_utf8("")); - EXPECT_TRUE(is_valid_utf8("plain/ascii")); - EXPECT_TRUE(is_valid_utf8("caf\xC3\xA9")); // U+00E9 - EXPECT_TRUE(is_valid_utf8("\xE6\xB5\x8B\xE8\xAF\x95")); // U+6D4B U+8BD5 - EXPECT_TRUE(is_valid_utf8("\xF0\x9F\x98\x80")); // U+1F600 - EXPECT_FALSE(is_valid_utf8("caf\xE9")); // Latin-1 - EXPECT_FALSE(is_valid_utf8("\xB2\xE2\xCA\xD4")); // GBK for U+6D4B U+8BD5 - EXPECT_FALSE(is_valid_utf8("\xC0\xAF")); // an overlong '/' - EXPECT_FALSE(is_valid_utf8("\xED\xA0\x80")); // a surrogate - EXPECT_FALSE(is_valid_utf8("\xF4\x90\x80\x80")); // above U+10FFFF - EXPECT_FALSE(is_valid_utf8("\xE6\xB5")); // truncated - EXPECT_FALSE(is_valid_utf8("\x80")); // a stray continuation -} +UTest utf8_validity_is_decided_byte_by_byte{"Glob/Utf8ValidityIsDecidedByteByByte", [] { + expect(is_valid_utf8("")); + expect(is_valid_utf8("plain/ascii")); + expect(is_valid_utf8("caf\xC3\xA9")); // U+00E9 + expect(is_valid_utf8("\xE6\xB5\x8B\xE8\xAF\x95")); // U+6D4B U+8BD5 + expect(is_valid_utf8("\xF0\x9F\x98\x80")); // U+1F600 + expect(!is_valid_utf8("caf\xE9")); // Latin-1 + expect(!is_valid_utf8("\xB2\xE2\xCA\xD4")); // GBK for U+6D4B U+8BD5 + expect(!is_valid_utf8("\xC0\xAF")); // an overlong '/' + expect(!is_valid_utf8("\xED\xA0\x80")); // a surrogate + expect(!is_valid_utf8("\xF4\x90\x80\x80")); // above U+10FFFF + expect(!is_valid_utf8("\xE6\xB5")); // truncated + expect(!is_valid_utf8("\x80")); // a stray continuation +}}; // A diagnostic names a path that has no UTF-8 spelling through an escaped one. // @@ -1174,47 +1162,46 @@ TEST(Glob, Utf8ValidityIsDecidedByteByByte) { // throw. The assertion that once followed the branches did exactly that, so // the test passed on an English runner and failed on a Chinese one (O6 of the // 2026-10-05 design). -TEST(Glob, EscapedSpellingIsUtf8WhateverTheName) { +UTest escaped_spelling_is_utf8_whatever_the_name{"Glob/EscapedSpellingIsUtf8WhateverTheName", [] { #ifdef _WIN32 const std::wstring lone{L'a', wchar_t(0xD800), L'b'}; - EXPECT_EQ(escaped_spelling(std::filesystem::path(lone)), "a\\u{D800}b"); - EXPECT_TRUE(is_valid_utf8(escaped_spelling(std::filesystem::path(lone)))); + expect(eq(escaped_spelling(std::filesystem::path(lone)), "a\\u{D800}b")); + expect(is_valid_utf8(escaped_spelling(std::filesystem::path(lone)))); const std::wstring cafe{L'c', L'a', L'f', wchar_t(0x00E9)}; - EXPECT_EQ(escaped_spelling(std::filesystem::path(cafe)), "caf\xC3\xA9"); + expect(eq(escaped_spelling(std::filesystem::path(cafe)), "caf\xC3\xA9")); #else - EXPECT_EQ(escaped_spelling("/x/caf\xE9"), "/x/caf\\xE9"); - EXPECT_EQ(escaped_spelling("/x/caf\xC3\xA9"), "/x/caf\xC3\xA9"); - EXPECT_TRUE(is_valid_utf8(escaped_spelling(std::filesystem::path("caf\xE9")))); + expect(eq(escaped_spelling("/x/caf\xE9"), "/x/caf\\xE9")); + expect(eq(escaped_spelling("/x/caf\xC3\xA9"), "/x/caf\xC3\xA9")); + expect(is_valid_utf8(escaped_spelling(std::filesystem::path("caf\xE9")))); #endif -} +}}; -TEST(Scanner, GlobWalkSkipsNamesThatAreNotUtf8) { +UTest glob_walk_skips_names_that_are_not_utf8{"Scanner/GlobWalkSkipsNamesThatAreNotUtf8", [] { #ifdef _WIN32 - GTEST_SKIP() << "a Windows name is UTF-16; the test above states the Windows case"; + skip("a Windows name is UTF-16; the test above states the Windows case"); #else auto dir = make_tempdir("mcpp-scanner-latin1"); std::error_code ec; std::filesystem::create_directories(dir / "caf\xE9", ec); // Latin-1 if (ec) { std::filesystem::remove_all(dir); - GTEST_SKIP() << "this file system refuses a name that is not UTF-8: " - << ec.message(); + skip([&] { std::ostringstream text; text << "this file system refuses a name that is not UTF-8: " << ec.message(); return text.str(); }()); } write(dir / "caf\xE9" / "x.h", "#pragma once\n"); write(dir / "zzz_ascii" / "x.h", "#pragma once\n"); (void)take_unnarrowable_paths(); std::vector files; - ASSERT_NO_THROW({ files = expand_glob(dir, "**/*.h"); }); - EXPECT_EQ(files, (std::vector{dir / "zzz_ascii" / "x.h"})); + expect(fatal(nothrow([&] { { files = expand_glob(dir, "**/*.h"); }; }))); + expect(eq(files, (std::vector{dir / "zzz_ascii" / "x.h"}))); auto notes = take_unnarrowable_paths(); - ASSERT_EQ(notes.size(), 1u); - EXPECT_EQ(notes[0], dir.generic_string()); + expect(fatal(eq(notes.size(), 1u))); + expect(eq(notes[0], dir.generic_string())); std::filesystem::remove_all(dir); #endif -} +}}; // `module : private;` is a THIRD production, not a spelling of the two the // scanner already knew ([module.private.frag]). It declares nothing: the unit @@ -1225,7 +1212,7 @@ TEST(Scanner, GlobWalkSkipsNamesThatAreNotUtf8) { // are checked because the scanner tokenises them differently — `module : // private;` yields the "name" `:` and `module :private;` yields `:private` — // and one of the two passing proves nothing about the other. -TEST(Scanner, PrivateModuleFragmentDeclaresNothing) { +UTest private_module_fragment_declares_nothing{"Scanner/PrivateModuleFragmentDeclaresNothing", [] { for (std::string_view spelling : {"module : private;", "module :private;"}) { auto dir = make_tempdir("mcpp-privfrag"); write(dir / "src" / "pf.cppm", @@ -1236,20 +1223,20 @@ TEST(Scanner, PrivateModuleFragmentDeclaresNothing) { auto u = scan_file(dir / "src" / "pf.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()) << spelling << ": " << u.error().format(); - ASSERT_TRUE(u->provides.has_value()) << spelling; - EXPECT_EQ(u->provides->logicalName, "pf") << spelling; - EXPECT_TRUE(u->providesInterface) << spelling; + if (auto&& failed = expect(fatal(u.has_value())); !failed) failed << spelling << ": " << u.error().format(); + expect(fatal(u->provides.has_value())) << spelling; + expect(eq(u->provides->logicalName, "pf")) << spelling; + expect(bool(u->providesInterface)) << spelling; // The fragment contributes no edge in either direction. - EXPECT_TRUE(u->requires_.empty()) << spelling; + expect(u->requires_.empty()) << spelling; } -} +}}; // A UTF-8 byte-order mark is not part of the source text. MSVC writes one by // default, so this is ordinary input; every compiler skips it, and this scanner // did not — the mark stayed attached to the first token, the declaration was // not seen, and the unit silently provided nothing. -TEST(Scanner, ByteOrderMarkDoesNotHideTheModuleDeclaration) { +UTest byte_order_mark_does_not_hide_the_module_declaration{"Scanner/ByteOrderMarkDoesNotHideTheModuleDeclaration", [] { auto dir = make_tempdir("mcpp-bom"); write(dir / "src" / "bom.cppm", "\xEF\xBB\xBF" @@ -1259,27 +1246,26 @@ TEST(Scanner, ByteOrderMarkDoesNotHideTheModuleDeclaration) { auto u = scan_file(dir / "src" / "bom.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()) << u.error().format(); - ASSERT_TRUE(u->provides.has_value()) + if (auto&& failed = expect(fatal(u.has_value())); !failed) failed << u.error().format(); + expect(fatal(u->provides.has_value())) << "the BOM hid the module declaration"; - EXPECT_EQ(u->provides->logicalName, "bom"); - ASSERT_EQ(u->requires_.size(), 1u); - EXPECT_EQ(u->requires_[0].logicalName, "std"); -} + expect(eq(u->provides->logicalName, "bom")); + expect(fatal(eq(u->requires_.size(), 1u))); + expect(eq(u->requires_[0].logicalName, "std")); +}}; // UTF-16/32 is refused by name rather than misread. Every line after the mark // would be garbage, so a named refusal is the difference between a message and // a mystery. -TEST(Scanner, Utf16ByteOrderMarkIsRefusedByName) { +UTest utf16_byte_order_mark_is_refused_by_name{"Scanner/Utf16ByteOrderMarkIsRefusedByName", [] { auto dir = make_tempdir("mcpp-bom16"); write(dir / "src" / "u16.cppm", std::string_view("\xFF\xFE" "e\0x\0p\0", 8)); auto u = scan_file(dir / "src" / "u16.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_FALSE(u.has_value()); - EXPECT_NE(u.error().message.find("UTF-16"), std::string::npos) - << u.error().message; -} + expect(fatal(!u.has_value())); + if (auto&& failed = expect(ne(u.error().message.find("UTF-16"), std::string::npos)); !failed) failed << u.error().message; +}}; // The scanner must never RECORD an identity no source could have declared. // @@ -1289,16 +1275,15 @@ TEST(Scanner, Utf16ByteOrderMarkIsRefusedByName) { // module fragment recorded a module called `:`, and the build graph grew an // edge for a BMI named after a colon. Nothing reported it — every step after // the mis-parse worked correctly on the answer it was given. -TEST(Scanner, AModuleIdentityThatIsNotANameIsRefused) { +UTest a_module_identity_that_is_not_a_name_is_refused{"Scanner/AModuleIdentityThatIsNotANameIsRefused", [] { auto dir = make_tempdir("mcpp-badname"); write(dir / "src" / "bad.cppm", "module :not_private;\n"); auto u = scan_file(dir / "src" / "bad.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_FALSE(u.has_value()); - EXPECT_NE(u.error().message.find("is not a module name"), std::string::npos) - << u.error().message; -} + expect(fatal(!u.has_value())); + if (auto&& failed = expect(ne(u.error().message.find("is not a module name"), std::string::npos)); !failed) failed << u.error().message; +}}; // The message quotes WHAT WAS READ, not what was recorded. // @@ -1309,24 +1294,21 @@ TEST(Scanner, AModuleIdentityThatIsNotANameIsRefused) { // every byte of a UTF-8 sequence. GCC 16.1 refuses the same declaration with // `unrecognized 'MODULE-EXPORT ...'`, so this is a clearer sentence for a // refusal that already existed, not a new restriction. -TEST(Scanner, AnUnreadableModuleNameIsQuotedAsWritten) { +UTest an_unreadable_module_name_is_quoted_as_written{"Scanner/AnUnreadableModuleNameIsQuotedAsWritten", [] { auto dir = make_tempdir("mcpp-nonascii"); write(dir / "src" / "u.cppm", "export module \u6a21\u5757;\n"); auto u = scan_file(dir / "src" / "u.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_FALSE(u.has_value()); - EXPECT_NE(u.error().message.find("is not a module name"), std::string::npos) - << u.error().message; - EXPECT_EQ(u.error().message.find("'' is not"), std::string::npos) - << "the message quoted an empty name: " << u.error().message; - EXPECT_NE(u.error().message.find("\u6a21\u5757"), std::string::npos) - << u.error().message; -} + expect(fatal(!u.has_value())); + if (auto&& failed = expect(ne(u.error().message.find("is not a module name"), std::string::npos)); !failed) failed << u.error().message; + if (auto&& failed = expect(eq(u.error().message.find("'' is not"), std::string::npos)); !failed) failed << "the message quoted an empty name: " << u.error().message; + if (auto&& failed = expect(ne(u.error().message.find("\u6a21\u5757"), std::string::npos)); !failed) failed << u.error().message; +}}; // The guard above must not refuse what the language allows. A partition, a // dotted name and a dotted partition are all names. -TEST(Scanner, WellFormedNamesSurviveTheIdentityGuard) { +UTest well_formed_names_survive_the_identity_guard{"Scanner/WellFormedNamesSurviveTheIdentityGuard", [] { struct Case { std::string_view decl; std::string_view provides; }; for (auto [decl, provides] : { Case{"export module a.b.c;", "a.b.c"}, @@ -1337,11 +1319,11 @@ TEST(Scanner, WellFormedNamesSurviveTheIdentityGuard) { write(dir / "src" / "g.cppm", std::format("{}\n", decl)); auto u = scan_file(dir / "src" / "g.cppm", "pkg", mcpp::builtin_extension_table()); - ASSERT_TRUE(u.has_value()) << decl << ": " << u.error().format(); - ASSERT_TRUE(u->provides.has_value()) << decl; - EXPECT_EQ(u->provides->logicalName, provides) << decl; + if (auto&& failed = expect(fatal(u.has_value())); !failed) failed << decl << ": " << u.error().format(); + expect(fatal(u->provides.has_value())) << decl; + expect(eq(u->provides->logicalName, provides)) << decl; } -} +}}; // ─── A module name is resolved in the importer's closure (mcpp#732) ───────── // @@ -1351,25 +1333,25 @@ TEST(Scanner, WellFormedNamesSurviveTheIdentityGuard) { // an import is resolved in the importing package's closure, and a closure may // hold one provider of a name. -TEST(ModuleResolution, OneProviderIsTakenWhereverItIs) { +UTest one_provider_is_taken_wherever_it_is{"ModuleResolution/OneProviderIsTakenWhereverItIs", [] { const std::vector providers{"lib"}; - EXPECT_EQ(choose_provider(providers, "app", {}), std::optional{0}); -} + expect(eq(choose_provider(providers, "app", {}), std::optional{0})); +}}; -TEST(ModuleResolution, TwoProvidersResolveInTheImportersClosure) { +UTest two_providers_resolve_in_the_importers_closure{"ModuleResolution/TwoProvidersResolveInTheImportersClosure", [] { const std::vector providers{"app", "updater"}; const Closures closures{{"app", {"app"}}, {"updater", {"updater"}}}; - EXPECT_EQ(choose_provider(providers, "app", closures), std::optional{0}); - EXPECT_EQ(choose_provider(providers, "updater", closures), std::optional{1}); -} + expect(eq(choose_provider(providers, "app", closures), std::optional{0})); + expect(eq(choose_provider(providers, "updater", closures), std::optional{1})); +}}; -TEST(ModuleResolution, TwoProvidersInOneClosureOrNoneResolveToNothing) { +UTest two_providers_in_one_closure_or_none_resolve_to_nothing{"ModuleResolution/TwoProvidersInOneClosureOrNoneResolveToNothing", [] { const std::vector providers{"lib1", "lib2"}; const Closures closures{{"prog", {"prog", "lib1", "lib2"}}, {"other", {"other"}}}; - EXPECT_FALSE(choose_provider(providers, "prog", closures)); - EXPECT_FALSE(choose_provider(providers, "other", closures)); - EXPECT_FALSE(choose_provider(providers, "prog", {})); -} + expect(!choose_provider(providers, "prog", closures)); + expect(!choose_provider(providers, "other", closures)); + expect(!choose_provider(providers, "prog", {})); +}}; namespace { // Two packages under `dir`, each providing module `boost` and importing it. @@ -1392,39 +1374,39 @@ std::string all_errors(const ScanResult& r) { } } // namespace -TEST(ModuleResolution, TwoProgramsEachResolveTheirOwnProvider) { +UTest two_programs_each_resolve_their_own_provider{"ModuleResolution/TwoProgramsEachResolveTheirOwnProvider", [] { auto dir = make_tempdir("mcpp-732-two-programs"); const Closures closures{{"app", {"app"}}, {"updater", {"updater"}}}; auto r = scan_packages(two_boost_packages(dir), closures); - ASSERT_TRUE(r.errors.empty()) << all_errors(r); - ASSERT_EQ(r.graph.providersOf.at("boost").size(), 2u); - EXPECT_FALSE(r.graph.producerOf.contains("boost")); + expect(fatal(r.errors.empty())) << all_errors(r); + expect(fatal(eq(r.graph.providersOf.at("boost").size(), 2u))); + expect(!r.graph.producerOf.contains("boost")); // Every edge joins a unit to the provider of its own package. - ASSERT_EQ(r.graph.edges.size(), 2u); + expect(fatal(eq(r.graph.edges.size(), 2u))); for (auto [consumer, producer] : r.graph.edges) - EXPECT_EQ(r.graph.units[consumer].packageName, r.graph.units[producer].packageName); + expect(eq(r.graph.units[consumer].packageName, r.graph.units[producer].packageName)); std::filesystem::remove_all(dir); -} +}}; -TEST(ModuleResolution, TwoProvidersInOneClosureAreRefused) { +UTest two_providers_in_one_closure_are_refused{"ModuleResolution/TwoProvidersInOneClosureAreRefused", [] { auto dir = make_tempdir("mcpp-732-one-closure"); const Closures closures{{"app", {"app", "updater"}}, {"updater", {"updater"}}}; auto r = scan_packages(two_boost_packages(dir), closures); const auto errors = all_errors(r); - EXPECT_NE(errors.find("module 'boost' is provided by package"), std::string::npos) << errors; - EXPECT_NE(errors.find("closure of package 'app'"), std::string::npos) << errors; + expect(ne(errors.find("module 'boost' is provided by package"), std::string::npos)) << errors; + expect(ne(errors.find("closure of package 'app'"), std::string::npos)) << errors; std::filesystem::remove_all(dir); -} +}}; -TEST(ModuleResolution, WithoutClosuresANameIsUniqueInTheGraph) { +UTest without_closures_a_name_is_unique_in_the_graph{"ModuleResolution/WithoutClosuresANameIsUniqueInTheGraph", [] { auto dir = make_tempdir("mcpp-732-no-closures"); auto r = scan_packages(two_boost_packages(dir)); const auto errors = all_errors(r); - EXPECT_NE(errors.find("module 'boost' is provided by package"), std::string::npos) << errors; + expect(ne(errors.find("module 'boost' is provided by package"), std::string::npos)) << errors; std::filesystem::remove_all(dir); -} +}}; -TEST(ModuleResolution, OneFileReachedTwiceIsNamedAsSuchWhateverItsSpelling) { +UTest one_file_reached_twice_is_named_as_such_whatever_its_spelling{"ModuleResolution/OneFileReachedTwiceIsNamedAsSuchWhateverItsSpelling", [] { auto dir = make_tempdir("mcpp-732-one-file"); write(dir / "shared" / "boost.cppm", "export module boost;\n"); std::vector packages; @@ -1438,52 +1420,52 @@ TEST(ModuleResolution, OneFileReachedTwiceIsNamedAsSuchWhateverItsSpelling) { const Closures closures{{"lib3", {"lib3", "lib4"}}, {"lib4", {"lib4"}}}; auto r = scan_packages(packages, closures); const auto errors = all_errors(r); - EXPECT_NE(errors.find("one file is reached as two packages"), std::string::npos) << errors; + expect(ne(errors.find("one file is reached as two packages"), std::string::npos)) << errors; std::filesystem::remove_all(dir); -} +}}; // A kept walk matched by the literal tail of a glob: `**/` also matches no // directory, so `**/main.cpp` matches a `main.cpp` at the root as well as one // below it, as the per-pattern walk did. `domain.cpp` ends with the same tail // and is still not a `main.cpp`. -TEST(Scanner, ADoubleStarSlashTailMatchesAtTheRoot) { +UTest a_double_star_slash_tail_matches_at_the_root{"Scanner/ADoubleStarSlashTailMatchesAtTheRoot", [] { auto dir = make_tempdir("mcpp-glob-tail"); write(dir / "main.cpp", "int main() {}\n"); write(dir / "sub" / "main.cpp", "int main() {}\n"); write(dir / "sub" / "other.cpp", "int x;\n"); write(dir / "sub" / "domain.cpp", "int y;\n"); auto files = expand_glob(dir, "**/main.cpp"); - ASSERT_EQ(files.size(), 2u); - EXPECT_EQ(files[0], dir / "main.cpp"); - EXPECT_EQ(files[1], dir / "sub" / "main.cpp"); + expect(fatal(eq(files.size(), 2u))); + expect(eq(files[0], dir / "main.cpp")); + expect(eq(files[1], dir / "sub" / "main.cpp")); std::filesystem::remove_all(dir); -} +}}; // `**/` stands for zero or more whole directories: what follows it starts a // path segment. It used to resume at any character, so `**/main.cpp` named // `src/domain.cpp` and `tests/**/test_*.cpp` named `tests/mytest_a.cpp`. // A `**` not followed by `/` still crosses segments and names. -TEST(Glob, ADoubleStarSlashStandsForWholeDirectories) { - EXPECT_TRUE(relative_path_matches_glob("main.cpp", "**/main.cpp")); - EXPECT_TRUE(relative_path_matches_glob("src/main.cpp", "**/main.cpp")); - EXPECT_TRUE(relative_path_matches_glob("src/a/b/main.cpp", "**/main.cpp")); - EXPECT_FALSE(relative_path_matches_glob("domain.cpp", "**/main.cpp")); - EXPECT_FALSE(relative_path_matches_glob("src/domain.cpp", "**/main.cpp")); - EXPECT_FALSE(relative_path_matches_glob("src/a/domain.cpp", "**/main.cpp")); - - EXPECT_TRUE(relative_path_matches_glob("tests/test_a.cpp", "tests/**/test_*.cpp")); - EXPECT_TRUE(relative_path_matches_glob("tests/x/test_a.cpp", "tests/**/test_*.cpp")); - EXPECT_FALSE(relative_path_matches_glob("tests/mytest_a.cpp", "tests/**/test_*.cpp")); - EXPECT_FALSE(relative_path_matches_glob("tests/x/mytest_a.cpp", "tests/**/test_*.cpp")); +UTest a_double_star_slash_stands_for_whole_directories{"Glob/ADoubleStarSlashStandsForWholeDirectories", [] { + expect(bool(relative_path_matches_glob("main.cpp", "**/main.cpp"))); + expect(bool(relative_path_matches_glob("src/main.cpp", "**/main.cpp"))); + expect(bool(relative_path_matches_glob("src/a/b/main.cpp", "**/main.cpp"))); + expect(!relative_path_matches_glob("domain.cpp", "**/main.cpp")); + expect(!relative_path_matches_glob("src/domain.cpp", "**/main.cpp")); + expect(!relative_path_matches_glob("src/a/domain.cpp", "**/main.cpp")); + + expect(bool(relative_path_matches_glob("tests/test_a.cpp", "tests/**/test_*.cpp"))); + expect(bool(relative_path_matches_glob("tests/x/test_a.cpp", "tests/**/test_*.cpp"))); + expect(!relative_path_matches_glob("tests/mytest_a.cpp", "tests/**/test_*.cpp")); + expect(!relative_path_matches_glob("tests/x/mytest_a.cpp", "tests/**/test_*.cpp")); const std::string_view dnn = "**/modules/dnn/**/*.avx2.cpp"; - EXPECT_TRUE(relative_path_matches_glob("modules/dnn/l.avx2.cpp", dnn)); - EXPECT_TRUE(relative_path_matches_glob("a/modules/dnn/b/l.avx2.cpp", dnn)); - EXPECT_FALSE(relative_path_matches_glob("a/submodules/dnn/l.avx2.cpp", dnn)); - EXPECT_FALSE(relative_path_matches_glob("modules/xdnn/l.avx2.cpp", dnn)); - - EXPECT_TRUE(relative_path_matches_glob("src/a/b.cpp", "src/**")); - EXPECT_TRUE(relative_path_matches_glob("src/a/b.cpp", "src/**.cpp")); - EXPECT_TRUE(relative_path_matches_glob("src/a/b.cpp", "**")); - EXPECT_FALSE(relative_path_matches_glob("srcx/a.cpp", "src/**/*.cpp")); -} + expect(bool(relative_path_matches_glob("modules/dnn/l.avx2.cpp", dnn))); + expect(bool(relative_path_matches_glob("a/modules/dnn/b/l.avx2.cpp", dnn))); + expect(!relative_path_matches_glob("a/submodules/dnn/l.avx2.cpp", dnn)); + expect(!relative_path_matches_glob("modules/xdnn/l.avx2.cpp", dnn)); + + expect(bool(relative_path_matches_glob("src/a/b.cpp", "src/**"))); + expect(bool(relative_path_matches_glob("src/a/b.cpp", "src/**.cpp"))); + expect(bool(relative_path_matches_glob("src/a/b.cpp", "**"))); + expect(!relative_path_matches_glob("srcx/a.cpp", "src/**/*.cpp")); +}}; diff --git a/tests/unit/test_module_address.cpp b/tests/unit/test_module_address.cpp index cc9341970..ec81cb261 100644 --- a/tests/unit/test_module_address.cpp +++ b/tests/unit/test_module_address.cpp @@ -15,9 +15,9 @@ // plans differ in both. It is the BMI counterpart of test_object_address.cpp // (mcpp#344). -#include import std; +import mcpplibs.mtest; import mcpp.build.plan; import mcpp.build.ninja; import mcpp.manifest; @@ -26,6 +26,8 @@ import mcpp.modgraph.scanner; import mcpp.source_kind; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + using namespace mcpp::build; namespace { @@ -165,70 +167,70 @@ void expect_same_core(mcpp::toolchain::CompilerId id) { Tmp t; std::string err; auto alone = plan_core(t, id, false, &err); - ASSERT_TRUE(alone) << err; + expect(fatal(bool(alone))) << err; auto beside = plan_core(t, id, true, &err); - ASSERT_TRUE(beside) << err; - ASSERT_EQ(alone->core.size(), 2u); - ASSERT_EQ(beside->core.size(), 2u); + expect(fatal(bool(beside))) << err; + expect(fatal(eq(alone->core.size(), 2u))); + expect(fatal(eq(beside->core.size(), 2u))); for (auto const& [file, a] : alone->core) { auto const& b = beside->core.at(file); - EXPECT_EQ(a.bmiFile, b.bmiFile) << file; - EXPECT_EQ(a.flags, b.flags) << file; - EXPECT_EQ(a.mapContent, b.mapContent) << file; - EXPECT_EQ(a.arguments, b.arguments) << file; + expect(eq(a.bmiFile, b.bmiFile)) << file; + expect(eq(a.flags, b.flags)) << file; + expect(eq(a.mapContent, b.mapContent)) << file; + expect(eq(a.arguments, b.arguments)) << file; } - EXPECT_EQ(alone->needsPic, beside->needsPic); + expect(eq(alone->needsPic, beside->needsPic)); } } // namespace // The load-bearing assertion: a provider of the same module name elsewhere in // the graph does not move `core`'s BMIs or change a word of its commands. -TEST(ModuleAddress, MemberCommandsAreImmuneToAnotherProviderOfTheName) { +UTest member_commands_are_immune_to_another_provider_of_the_name{"ModuleAddress/MemberCommandsAreImmuneToAnotherProviderOfTheName", [] { expect_same_core(mcpp::toolchain::CompilerId::GCC); expect_same_core(mcpp::toolchain::CompilerId::Clang); -} +}}; // Every BMI of a package other than the root lies below that package's // directory, and a unit is told where through one map that lists its own // module and what it reaches through its imports, and nothing else. -TEST(ModuleAddress, MemberBmisAreQualifiedAndTheMapIsTheUnitsReach) { +UTest member_bmis_are_qualified_and_the_map_is_the_units_reach{"ModuleAddress/MemberBmisAreQualifiedAndTheMapIsTheUnitsReach", [] { Tmp t; std::string err; auto gcc = plan_core(t, mcpp::toolchain::CompilerId::GCC, true, &err); - ASSERT_TRUE(gcc) << err; + expect(fatal(bool(gcc))) << err; auto const& iface = gcc->core.at("core.cppm"); - EXPECT_EQ(iface.bmiFile, "core/corelib.gcm"); + expect(eq(iface.bmiFile, "core/corelib.gcm")); // GCC's map is rooted at the BMI directory, as its default mapper is, so // a BMI records its imports at the same paths whichever mapper made it. - EXPECT_TRUE(iface.mapContent.starts_with("$root gcm.cache\n")) << iface.mapContent; - EXPECT_NE(iface.mapContent.find("corelib core/corelib.gcm\n"), std::string::npos); - EXPECT_NE(iface.mapContent.find("m core/m.gcm\n"), std::string::npos); - EXPECT_NE(iface.mapContent.find("std std.gcm\n"), std::string::npos); - EXPECT_EQ(iface.mapContent.find("tool"), std::string::npos) + expect(iface.mapContent.starts_with("$root gcm.cache\n")) << iface.mapContent; + expect(ne(iface.mapContent.find("corelib core/corelib.gcm\n"), std::string::npos)); + expect(ne(iface.mapContent.find("m core/m.gcm\n"), std::string::npos)); + expect(ne(iface.mapContent.find("std std.gcm\n"), std::string::npos)); + expect(eq(iface.mapContent.find("tool"), std::string::npos)) << "the map names a module the unit does not reach:\n" << iface.mapContent; - ASSERT_FALSE(iface.flags.empty()); - EXPECT_TRUE(iface.flags.back().find("-fmodule-mapper=modmap/core-") != std::string::npos) + expect(fatal(!iface.flags.empty())); + expect(iface.flags.back().find("-fmodule-mapper=modmap/core-") != std::string::npos) << iface.flags.back(); auto clang = plan_core(t, mcpp::toolchain::CompilerId::Clang, true, &err); - ASSERT_TRUE(clang) << err; + expect(fatal(bool(clang))) << err; auto const& ci = clang->core.at("core.cppm"); - EXPECT_EQ(ci.bmiFile, "core/corelib.pcm"); + expect(eq(ci.bmiFile, "core/corelib.pcm")); // clang reads the imported module's BMI from the argument file; its own // module is written through -fmodule-output and is not listed there. - ASSERT_EQ(ci.arguments.size(), 1u); - EXPECT_TRUE(ci.arguments.front().starts_with("-fmodule-file=m=")); - EXPECT_TRUE(ci.arguments.front().ends_with("pcm.cache/core/m.pcm")) << ci.arguments.front(); - ASSERT_FALSE(ci.flags.empty()); - EXPECT_TRUE(ci.flags.back().find(".modmap") != std::string::npos) << ci.flags.back(); - EXPECT_EQ(ci.flags.back().front(), '@'); -} + expect(fatal(eq(ci.arguments.size(), 1u))); + expect(ci.arguments.front().starts_with("-fmodule-file=m=")); + expect(ci.arguments.front().ends_with("pcm.cache/core/m.pcm")) << ci.arguments.front(); + expect(fatal(!ci.flags.empty())); + expect(ci.flags.back().find(".modmap") != std::string::npos) << ci.flags.back(); + expect(eq(ci.flags.back().front(), '@')); +}}; // The root package's modules stay at their names, and a unit that reaches only // those and `std` is told nothing: a project without dependency modules is laid // out, and every command spelled, as before. -TEST(ModuleAddress, RootModulesStayFlatAndNeedNoMap) { +UTest root_modules_stay_flat_and_need_no_map{"ModuleAddress/RootModulesStayFlatAndNeedNoMap", [] { Tmp t; const auto proj = t.path / "proj"; mcpp::manifest::Manifest root; @@ -251,25 +253,25 @@ TEST(ModuleAddress, RootModulesStayFlatAndNeedNoMap) { for (auto id : {mcpp::toolchain::CompilerId::GCC, mcpp::toolchain::CompilerId::Clang}) { auto plan = make_plan(root, toolchain(id), {}, graph, topo, packages, proj, proj / "target" / "t", {}, {}, {}); - ASSERT_TRUE(plan) << plan.error(); - EXPECT_TRUE(plan->moduleScopes.empty()); + if (auto&& failed = expect(fatal(bool(plan))); !failed) failed << plan.error(); + expect(plan->moduleScopes.empty()); for (auto const& cu : plan->compileUnits) { - EXPECT_TRUE(cu.moduleScope.empty()) << cu.source; + expect(cu.moduleScope.empty()) << cu.source; if (!cu.providesModule.empty()) - EXPECT_EQ(cu.bmiFile.find('/'), std::string::npos) << cu.bmiFile; + expect(eq(cu.bmiFile.find('/'), std::string::npos)) << cu.bmiFile; } } -} +}}; // Position independence is a property of the target (B2): every unit of a // hosted ELF target is compiled position-independent whether or not the graph // links a shared library, and a freestanding target's units are not. -TEST(ModuleAddress, PositionIndependenceFollowsTheTargetNotTheGraph) { +UTest position_independence_follows_the_target_not_the_graph{"ModuleAddress/PositionIndependenceFollowsTheTargetNotTheGraph", [] { Tmp t; std::string err; auto planned = plan_core(t, mcpp::toolchain::CompilerId::GCC, false, &err); - ASSERT_TRUE(planned) << err; - EXPECT_TRUE(planned->needsPic); + expect(fatal(bool(planned))) << err; + expect(bool(planned->needsPic)); const auto proj = t.path / "bare"; mcpp::manifest::Manifest root; @@ -286,41 +288,41 @@ TEST(ModuleAddress, PositionIndependenceFollowsTheTargetNotTheGraph) { tc.targetTriple = "arm-none-eabi"; auto plan = make_plan(root, tc, {}, graph, {0}, packages, proj, proj / "target" / "t", {}, {}, {}); - ASSERT_TRUE(plan) << plan.error(); - EXPECT_FALSE(plan->needsPic); -} + if (auto&& failed = expect(fatal(bool(plan))); !failed) failed << plan.error(); + expect(!plan->needsPic); +}}; // Every argument file a unit names is the one its own map states, with the // arguments the backend writes into it. A module and a unit importing it in // one package can list the same BMIs while only the importer loads one; when // the two shared a key, the importer named a file nothing wrote. -TEST(ModuleAddress, EveryNamedArgumentFileIsOneThePlanStates) { +UTest every_named_argument_file_is_one_the_plan_states{"ModuleAddress/EveryNamedArgumentFileIsOneThePlanStates", [] { Tmp t; auto plan = plan_workspace(t, mcpp::toolchain::CompilerId::Clang, true); - ASSERT_TRUE(plan) << plan.error(); + if (auto&& failed = expect(fatal(bool(plan))); !failed) failed << plan.error(); std::size_t named = 0; for (auto const& cu : plan->compileUnits) { for (auto const& f : cu.packageCxxflags) { if (!f.starts_with("@")) continue; ++named; auto it = plan->moduleScopes.find(cu.moduleScope); - ASSERT_NE(it, plan->moduleScopes.end()) << cu.source; - EXPECT_FALSE(it->second.arguments.empty()) << cu.source; - EXPECT_FALSE(it->second.argsFile.empty()) << cu.source; - EXPECT_TRUE(f.ends_with(it->second.argsFile.filename().string())) << f; + expect(fatal(ne(it, plan->moduleScopes.end()))) << cu.source; + expect(!(it->second.arguments.empty())) << cu.source; + expect(!(it->second.argsFile.empty())) << cu.source; + expect(f.ends_with(it->second.argsFile.filename().string())) << f; } } // core.cppm, app's main.cpp and tool's main.cpp import a module of // another package or of a member. - EXPECT_EQ(named, 3u); -} + expect(eq(named, 3u)); +}}; // The argument file of a module map, as each compiler reads it. MSVC takes an // option's value only from the same line of a command file (`D8004: '/reference' // requires an argument` on windows-2025 when they were on two lines), and reads // a non-ASCII path only with a byte order mark; clang reads GNU rules, where a // word with a space is quoted. -TEST(ModuleAddress, ArgumentFilesAreWrittenAsEachCompilerReadsThem) { +UTest argument_files_are_written_as_each_compiler_reads_them{"ModuleAddress/ArgumentFilesAreWrittenAsEachCompilerReadsThem", [] { Tmp t; auto read = [](const std::filesystem::path& p) { std::ifstream in(p, std::ios::binary); @@ -341,10 +343,9 @@ TEST(ModuleAddress, ArgumentFilesAreWrittenAsEachCompilerReadsThem) { std::filesystem::create_directories(msvc.outputDir / "modmap"); std::ofstream(msvc.outputDir / "modmap/core-1.modmap") << "/reference m=D:/old/m.ifc\n"; write_module_maps(msvc); - EXPECT_EQ(read(msvc.outputDir / "modmap/core-1.modmap"), - "\xEF\xBB\xBF/reference m=C:/build/ifc.cache/core/m.ifc\n" - "/reference n=C:/build/ifc.cache/core/n.ifc\n"); - EXPECT_EQ(read(msvc.outputDir / "modmap/core-1.map"), ms.content); + expect(eq(read(msvc.outputDir / "modmap/core-1.modmap"), "\xEF\xBB\xBF/reference m=C:/build/ifc.cache/core/m.ifc\n" + "/reference n=C:/build/ifc.cache/core/n.ifc\n")); + expect(eq(read(msvc.outputDir / "modmap/core-1.map"), ms.content)); BuildPlan clang; clang.outputDir = t.path / "clang"; @@ -357,7 +358,6 @@ TEST(ModuleAddress, ArgumentFilesAreWrittenAsEachCompilerReadsThem) { "-fmodule-file=n=/build/pcm.cache/core/n.pcm"}; clang.moduleScopes.emplace("core-2", cs); write_module_maps(clang); - EXPECT_EQ(read(clang.outputDir / "modmap/core-2.modmap"), - "\"-fmodule-file=m=/build dir/pcm.cache/core/m.pcm\"\n" - "-fmodule-file=n=/build/pcm.cache/core/n.pcm\n"); -} + expect(eq(read(clang.outputDir / "modmap/core-2.modmap"), "\"-fmodule-file=m=/build dir/pcm.cache/core/m.pcm\"\n" + "-fmodule-file=n=/build/pcm.cache/core/n.pcm\n")); +}}; diff --git a/tests/unit/test_msvc_coroutines.cpp b/tests/unit/test_msvc_coroutines.cpp index 5ecf76af6..77b4acb64 100644 --- a/tests/unit/test_msvc_coroutines.cpp +++ b/tests/unit/test_msvc_coroutines.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.msvc_coroutines; +using namespace mcpplibs::mtest; + // The note a failed compile gets when clang does not support coroutines on the // MSVC ABI it targets (LLVM 23.1.3 Part 3, D1). The outputs below are copied // from probe PR #788 (run 37792334189): clang 23.1.3, i686-pc-windows-msvc, @@ -61,74 +63,74 @@ std::string with_standard(std::string_view message, std::string_view from, std:: return s; } -TEST(MsvcCoroutines, StdModuleMessageWithoutDiagnosticsGetsTheNote) { +UTest std_module_message_without_diagnostics_gets_the_note{"MsvcCoroutines/StdModuleMessageWithoutDiagnosticsGetsTheNote", [] { auto note = co::std_module_advice(kStdModuleMessage, answering(false)); - ASSERT_FALSE(note.empty()); - EXPECT_NE(note.find(""), std::string::npos); - EXPECT_NE(note.find("standard = \"c++20\""), std::string::npos); - EXPECT_NE(note.find("[target.i686-windows-msvc]"), std::string::npos); -} + expect(fatal(!note.empty())); + expect(ne(note.find(""), std::string::npos)); + expect(ne(note.find("standard = \"c++20\""), std::string::npos)); + expect(ne(note.find("[target.i686-windows-msvc]"), std::string::npos)); +}}; -TEST(MsvcCoroutines, StdModuleBelowCxx23IsNotThisFailure) { +UTest std_module_below_cxx23_is_not_this_failure{"MsvcCoroutines/StdModuleBelowCxx23IsNotThisFailure", [] { int calls = 0; auto cxx20 = with_standard(kStdModuleMessage, "-std=c++23", "-std=c++20"); - EXPECT_TRUE(co::std_module_advice(cxx20, answering(false, &calls)).empty()); - EXPECT_EQ(calls, 0); + expect(co::std_module_advice(cxx20, answering(false, &calls)).empty()); + expect(eq(calls, 0)); for (auto spelling : {"-std=c++2b", "-std=c++26", "-std=gnu++23"}) { auto later = with_standard(kStdModuleMessage, "-std=c++23", spelling); - EXPECT_FALSE(co::std_module_advice(later, answering(false)).empty()) << spelling; + expect(!(co::std_module_advice(later, answering(false)).empty())) << spelling; } -} +}}; -TEST(MsvcCoroutines, StdModuleOfLibcxxIsNotThisFailure) { +UTest std_module_of_libcxx_is_not_this_failure{"MsvcCoroutines/StdModuleOfLibcxxIsNotThisFailure", [] { int calls = 0; auto libcxx = with_standard(kStdModuleMessage, "modules\\std.ixx", "share\\libc++\\v1\\std.cppm"); - EXPECT_TRUE(co::std_module_advice(libcxx, answering(false, &calls)).empty()); - EXPECT_EQ(calls, 0); -} + expect(co::std_module_advice(libcxx, answering(false, &calls)).empty()); + expect(eq(calls, 0)); +}}; -TEST(MsvcCoroutines, StdModuleOnACompilerThatPredefinesTheMacroGetsNoNote) { - EXPECT_TRUE(co::std_module_advice(kStdModuleMessage, answering(true)).empty()); -} +UTest std_module_on_a_compiler_that_predefines_the_macro_gets_no_note{"MsvcCoroutines/StdModuleOnACompilerThatPredefinesTheMacroGetsNoNote", [] { + expect(co::std_module_advice(kStdModuleMessage, answering(true)).empty()); +}}; -TEST(MsvcCoroutines, StdModuleFailureIsTheGeneratorSymptom) { - EXPECT_EQ(co::symptom_in(kStdModuleFailure), co::Symptom::StdModuleGenerator); +UTest std_module_failure_is_the_generator_symptom{"MsvcCoroutines/StdModuleFailureIsTheGeneratorSymptom", [] { + expect(eq(co::symptom_in(kStdModuleFailure), co::Symptom::StdModuleGenerator)); auto cmd = co::failed_command_in(kStdModuleFailure); - ASSERT_TRUE(cmd.has_value()); - EXPECT_TRUE(cmd->compiler.ends_with("clang++.exe")); - EXPECT_EQ(cmd->target, "i686-pc-windows-msvc"); - EXPECT_EQ(cmd->standard, "c++23"); -} + expect(fatal(cmd.has_value())); + expect(cmd->compiler.ends_with("clang++.exe")); + expect(eq(cmd->target, "i686-pc-windows-msvc")); + expect(eq(cmd->standard, "c++23")); +}}; -TEST(MsvcCoroutines, StdModuleNoteOffersCxx20FirstAndLlvm22Optionally) { +UTest std_module_note_offers_cxx20_first_and_llvm22_optionally{"MsvcCoroutines/StdModuleNoteOffersCxx20FirstAndLlvm22Optionally", [] { auto note = co::advice(kStdModuleFailure, answering(false)); - ASSERT_FALSE(note.empty()); - EXPECT_NE(note.find("clang 23.1.3 does not support C++20 coroutines"), std::string::npos); - EXPECT_NE(note.find(""), std::string::npos); + expect(fatal(!note.empty())); + expect(ne(note.find("clang 23.1.3 does not support C++20 coroutines"), std::string::npos)); + expect(ne(note.find(""), std::string::npos)); const auto cxx20 = note.find("standard = \"c++20\""); const auto llvm22 = note.find("toolchain = \"llvm@22.1.8\""); - ASSERT_NE(cxx20, std::string::npos); - ASSERT_NE(llvm22, std::string::npos); - EXPECT_LT(cxx20, llvm22); + expect(fatal(ne(cxx20, std::string::npos))); + expect(fatal(ne(llvm22, std::string::npos))); + expect(lt(cxx20, llvm22)); // The section uses mcpp's spelling of the target. - EXPECT_NE(note.find("[target.i686-windows-msvc]"), std::string::npos); - EXPECT_NE(note.find("Defining __cpp_impl_coroutine yourself is not a fix"), std::string::npos); -} + expect(ne(note.find("[target.i686-windows-msvc]"), std::string::npos)); + expect(ne(note.find("Defining __cpp_impl_coroutine yourself is not a fix"), std::string::npos)); +}}; -TEST(MsvcCoroutines, CoroutineUseNoteDoesNotOfferCxx20) { - EXPECT_EQ(co::symptom_in(kCoroutineUseFailure), co::Symptom::CoroutineUse); +UTest coroutine_use_note_does_not_offer_cxx20{"MsvcCoroutines/CoroutineUseNoteDoesNotOfferCxx20", [] { + expect(eq(co::symptom_in(kCoroutineUseFailure), co::Symptom::CoroutineUse)); auto cmd = co::failed_command_in(kCoroutineUseFailure); - ASSERT_TRUE(cmd.has_value()); - EXPECT_EQ(cmd->standard, "c++20"); + expect(fatal(cmd.has_value())); + expect(eq(cmd->standard, "c++20")); auto note = co::advice(kCoroutineUseFailure, answering(false)); - ASSERT_FALSE(note.empty()); - EXPECT_NE(note.find("Code that uses coroutines"), std::string::npos); - EXPECT_EQ(note.find("c++20\""), std::string::npos); - EXPECT_NE(note.find("llvm@22.1.8"), std::string::npos); -} + expect(fatal(!note.empty())); + expect(ne(note.find("Code that uses coroutines"), std::string::npos)); + expect(eq(note.find("c++20\""), std::string::npos)); + expect(ne(note.find("llvm@22.1.8"), std::string::npos)); +}}; // A compile run through the capped executor names its compiler after `--`. -TEST(MsvcCoroutines, CoroutineUseThroughTheCappedExecutorGetsTheNote) { +UTest coroutine_use_through_the_capped_executor_gets_the_note{"MsvcCoroutines/CoroutineUseThroughTheCappedExecutorGetsTheNote", [] { constexpr std::string_view failure = "FAILED: obj/main.o \n" "C:/mcpp/bin/mcpp.exe __compiler-cap --sem .mcpp-sched --output obj/main.o --depfile obj/main.o.d -- " @@ -137,55 +139,55 @@ TEST(MsvcCoroutines, CoroutineUseThroughTheCappedExecutorGetsTheNote) { "D:/a/_temp/src/main.cpp:2:71: error: use of undeclared identifier 'std'\n" "D:/a/_temp/src/main.cpp:3:9: error: std::coroutine_traits type was not found; include before defining a coroutine\n"; auto cmd = co::failed_command_in(failure); - ASSERT_TRUE(cmd.has_value()); - EXPECT_TRUE(cmd->compiler.ends_with("clang++.exe")); - EXPECT_EQ(cmd->target, "i686-pc-windows-msvc"); - EXPECT_FALSE(co::advice(failure, answering(false)).empty()); -} + expect(fatal(cmd.has_value())); + expect(cmd->compiler.ends_with("clang++.exe")); + expect(eq(cmd->target, "i686-pc-windows-msvc")); + expect(!(co::advice(failure, answering(false)).empty())); +}}; -TEST(MsvcCoroutines, CompilerThatPredefinesTheMacroGetsNoNote) { - EXPECT_TRUE(co::advice(kStdModuleFailure, answering(true)).empty()); -} +UTest compiler_that_predefines_the_macro_gets_no_note{"MsvcCoroutines/CompilerThatPredefinesTheMacroGetsNoNote", [] { + expect(co::advice(kStdModuleFailure, answering(true)).empty()); +}}; -TEST(MsvcCoroutines, ProbeFailureGetsNoNote) { +UTest probe_failure_gets_no_note{"MsvcCoroutines/ProbeFailureGetsNoNote", [] { co::ProbeFn none = [](const co::FailedCommand&) -> std::optional { return std::nullopt; }; - EXPECT_TRUE(co::advice(kStdModuleFailure, none).empty()); -} + expect(co::advice(kStdModuleFailure, none).empty()); +}}; -TEST(MsvcCoroutines, UnrelatedFailureDoesNotRunTheProbe) { +UTest unrelated_failure_does_not_run_the_probe{"MsvcCoroutines/UnrelatedFailureDoesNotRunTheProbe", [] { int calls = 0; std::string other = "FAILED: obj/main.o \n" "clang++.exe -std=c++23 --target=i686-pc-windows-msvc -c main.cpp\n" "main.cpp:1:10: fatal error: 'missing.h' file not found\n"; - EXPECT_TRUE(co::advice(other, answering(false, &calls)).empty()); - EXPECT_EQ(calls, 0); -} + expect(co::advice(other, answering(false, &calls)).empty()); + expect(eq(calls, 0)); +}}; -TEST(MsvcCoroutines, NonMsvcTargetDoesNotRunTheProbe) { +UTest non_msvc_target_does_not_run_the_probe{"MsvcCoroutines/NonMsvcTargetDoesNotRunTheProbe", [] { int calls = 0; std::string gnu = "FAILED: obj/main.o \n" "clang++ -std=c++20 --target=i686-pc-windows-gnu -c main.cpp\n" "main.cpp:3:9: error: std::coroutine_traits type was not found\n"; - EXPECT_TRUE(co::advice(gnu, answering(false, &calls)).empty()); - EXPECT_EQ(calls, 0); -} + expect(co::advice(gnu, answering(false, &calls)).empty()); + expect(eq(calls, 0)); +}}; -TEST(MsvcCoroutines, OutputWithoutACommandGetsNoNote) { +UTest output_without_a_command_gets_no_note{"MsvcCoroutines/OutputWithoutACommandGetsNoNote", [] { std::string bare = "main.cpp:3:9: error: std::coroutine_traits type was not found\n"; - EXPECT_TRUE(co::advice(bare, answering(false)).empty()); -} + expect(co::advice(bare, answering(false)).empty()); +}}; -TEST(MsvcCoroutines, NoteWithoutVersionNamesTheCompilerGenerically) { +UTest note_without_version_names_the_compiler_generically{"MsvcCoroutines/NoteWithoutVersionNamesTheCompilerGenerically", [] { co::ProbeFn unversioned = [](const co::FailedCommand&) -> std::optional { return co::Probe{false, ""}; }; auto note = co::advice(kStdModuleFailure, unversioned); - EXPECT_NE(note.find("this clang does not support"), std::string::npos); -} + expect(ne(note.find("this clang does not support"), std::string::npos)); +}}; } // namespace diff --git a/tests/unit/test_network.cpp b/tests/unit/test_network.cpp index e30940548..fc94ae26e 100644 --- a/tests/unit/test_network.cpp +++ b/tests/unit/test_network.cpp @@ -1,12 +1,14 @@ -#include import std; +import mcpplibs.mtest; import mcpp.provider; import mcpp.provider.xlings; import mcpplibs.os.platform; import mcpp.invocation; import mcpp.run; +using namespace mcpplibs::mtest; + namespace { struct NetworkFixture { std::filesystem::path dir = std::filesystem::temp_directory_path() @@ -28,25 +30,25 @@ fi }; } -TEST(NetworkCheck, OfflineNeverInvokesTheProvider) { - if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "POSIX subprocess fixture"; +UTest offline_never_invokes_the_provider{"NetworkCheck/OfflineNeverInvokesTheProvider", [] { + if constexpr (mcpplibs::os::is_windows) skip("POSIX subprocess fixture"); NetworkFixture f(R"SH(touch "$XLINGS_HOME/invoked" exit 1 )SH"); { mcpp::invocation::ScopedEnv online("MCPP_OFFLINE", "0"); - EXPECT_EQ(mcpp::provider::xlings::network_check(f.env, std::chrono::seconds(1)).status, "unsupported"); - ASSERT_TRUE(std::filesystem::exists(f.dir / "invoked")); + expect(eq(mcpp::provider::xlings::network_check(f.env, std::chrono::seconds(1)).status, "unsupported")); + expect(fatal(std::filesystem::exists(f.dir / "invoked"))); std::filesystem::remove(f.dir / "invoked"); } mcpp::invocation::ScopedEnv offline("MCPP_OFFLINE", "1"); const auto result = mcpp::provider::xlings::network_check(f.env, std::chrono::milliseconds(100)); - EXPECT_EQ(result.status, "offline"); - EXPECT_FALSE(std::filesystem::exists(f.dir / "invoked")); -} + expect(eq(result.status, "offline")); + expect(!std::filesystem::exists(f.dir / "invoked")); +}}; -TEST(NetworkCheck, DiscoveryAndProbesShareOneDeadline) { - if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "POSIX subprocess fixture"; +UTest discovery_and_probes_share_one_deadline{"NetworkCheck/DiscoveryAndProbesShareOneDeadline", [] { + if constexpr (mcpplibs::os::is_windows) skip("POSIX subprocess fixture"); NetworkFixture f(R"SH( if [ "$2" = "network_endpoints" ]; then echo '{"kind":"data","dataKind":"network_endpoints","payload":{"endpoints":[{"url":"https://one.invalid"},{"url":"https://two.invalid"},{"url":"https://three.invalid"},{"url":"https://four.invalid"},{"url":"https://five.invalid"}]}}' @@ -57,13 +59,13 @@ fi mcpp::invocation::ScopedEnv offline("MCPP_OFFLINE", "0"); const auto start = std::chrono::steady_clock::now(); const auto result = mcpp::provider::xlings::network_check(f.env, std::chrono::milliseconds(400)); - EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds(2)); - ASSERT_EQ(result.endpoints.size(), 5u); - for (const auto& e : result.endpoints) EXPECT_EQ(e.status, "timeout"); -} + expect(lt(std::chrono::steady_clock::now() - start, std::chrono::seconds(2))); + expect(fatal(eq(result.endpoints.size(), 5u))); + for (const auto& e : result.endpoints) expect(eq(e.status, "timeout")); +}}; -TEST(NetworkCheck, MalformedProviderDataDoesNotEscapeDiagnosis) { - if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "POSIX subprocess fixture"; +UTest malformed_provider_data_does_not_escape_diagnosis{"NetworkCheck/MalformedProviderDataDoesNotEscapeDiagnosis", [] { + if constexpr (mcpplibs::os::is_windows) skip("POSIX subprocess fixture"); NetworkFixture f(R"SH( if [ "$2" = "network_endpoints" ]; then echo '{"kind":"data","dataKind":"network_endpoints","payload":{"endpoints":[{"url":"https://one.invalid"}]}}' @@ -73,11 +75,11 @@ fi )SH"); mcpp::invocation::ScopedEnv offline("MCPP_OFFLINE", "0"); const auto result = mcpp::provider::xlings::network_check(f.env, std::chrono::seconds(1)); - ASSERT_EQ(result.endpoints.size(), 1u); - EXPECT_EQ(result.endpoints.front().status, "invalid_response"); -} + expect(fatal(eq(result.endpoints.size(), 1u))); + expect(eq(result.endpoints.front().status, "invalid_response")); +}}; -TEST(NetworkCheck, HeartbeatsCannotKeepAByteStalledDownloadAlive) { +UTest heartbeats_cannot_keep_a_byte_stalled_download_alive{"NetworkCheck/HeartbeatsCannotKeepAByteStalledDownloadAlive", [] { using namespace std::chrono; mcpp::provider::xlings::DownloadWatchdog watchdog(seconds(30)); const auto start = steady_clock::now(); @@ -85,24 +87,24 @@ TEST(NetworkCheck, HeartbeatsCannotKeepAByteStalledDownloadAlive) { p.files.push_back({.name = "tool", .started = true, .sourceAttempt = 1}); watchdog.observe(p, start); watchdog.observe(p, start + seconds(29)); // repeated protocol sample - EXPECT_TRUE(watchdog.expired(start + seconds(30))); + expect(bool(watchdog.expired(start + seconds(30)))); p.files.front().downloadedBytes = 1; watchdog.observe(p, start + seconds(30)); - EXPECT_FALSE(watchdog.expired(start + seconds(59))); + expect(!(watchdog.expired(start + seconds(59)))); p.files.front().sourceAttempt = 2; p.files.front().downloadedBytes = 0; watchdog.observe(p, start + seconds(60)); - EXPECT_FALSE(watchdog.expired(start + seconds(89))); + expect(!(watchdog.expired(start + seconds(89)))); p.files.front().finished = true; watchdog.observe(p, start + seconds(90)); - EXPECT_FALSE(watchdog.expired(start + hours(3))); -} + expect(!(watchdog.expired(start + hours(3)))); +}}; // xlings leaves a source that sent no byte for kSourceIdleTimeout and reports // the next one; mcpp's own bound is longer, so the next source is reached. // With the two equal, a source that connected and then sent nothing stopped the // install at attempt 1 (CI, 2026-11-02). -TEST(NetworkCheck, ASilentSourceIsLeftBeforeTheInstallStops) { +UTest a_silent_source_is_left_before_the_install_stops{"NetworkCheck/ASilentSourceIsLeftBeforeTheInstallStops", [] { using namespace std::chrono; namespace x = mcpp::provider::xlings; static_assert(x::kSourceConnectTimeout < x::kSourceIdleTimeout); @@ -113,32 +115,30 @@ TEST(NetworkCheck, ASilentSourceIsLeftBeforeTheInstallStops) { mcpp::provider::BootstrapProgress p; p.files.push_back({.name = "pkg", .started = true, .sourceAttempt = 1}); watchdog.observe(p, start); - EXPECT_FALSE(watchdog.expired(left)); + expect(!watchdog.expired(left)); p.files.front().sourceAttempt = 2; watchdog.observe(p, left); - EXPECT_FALSE(watchdog.expired(left + x::kSourceIdleTimeout)); - EXPECT_TRUE(watchdog.expired(left + x::kDownloadStallTimeout)); -} + expect(!watchdog.expired(left + x::kSourceIdleTimeout)); + expect(bool(watchdog.expired(left + x::kDownloadStallTimeout))); +}}; -TEST(NetworkCheck, AnotherDownloadCannotRenewAStalledFilesBudget) { +UTest another_download_cannot_renew_a_stalled_files_budget{"NetworkCheck/AnotherDownloadCannotRenewAStalledFilesBudget", [] { using namespace std::chrono; namespace x = mcpp::provider::xlings; const auto start = steady_clock::now(); x::InstallDeadline deadline(seconds(120), seconds(45), start); deadline.observe(R"({"kind":"data","dataKind":"download_progress","payload":{"stream":"install:1","files":[{"name":"stalled","started":true,"finished":false,"sourceAttempt":1,"downloadedBytes":0},{"name":"healthy","started":true,"finished":false,"sourceAttempt":1,"downloadedBytes":1}]}})", start); deadline.observe(R"({"kind":"data","dataKind":"download_progress","payload":{"stream":"install:1","files":[{"name":"stalled","started":true,"finished":false,"sourceAttempt":1,"downloadedBytes":0},{"name":"healthy","started":true,"finished":false,"sourceAttempt":1,"downloadedBytes":100}]}})", start + seconds(44)); - EXPECT_FALSE(deadline.expired(start + seconds(44))); - EXPECT_EQ(deadline.failure_reason(start + seconds(45)), - "download 'stalled' made no byte progress or source change for 45s (attempt 1)"); + expect(!(deadline.expired(start + seconds(44)))); + expect(eq(deadline.failure_reason(start + seconds(45)), "download 'stalled' made no byte progress or source change for 45s (attempt 1)")); deadline.observe(R"({"kind":"data","dataKind":"download_progress","payload":{"stream":"install:1","files":[{"name":"stalled","started":true,"finished":false,"sourceAttempt":2,"downloadedBytes":0},{"name":"healthy","started":true,"finished":true,"sourceAttempt":1,"downloadedBytes":100}]}})", start + seconds(46)); - EXPECT_TRUE(deadline.failure_reason(start + seconds(90)).empty()); - EXPECT_EQ(deadline.failure_reason(start + seconds(91)), - "download 'stalled' made no byte progress or source change for 45s (attempt 2)"); + expect(deadline.failure_reason(start + seconds(90)).empty()); + expect(eq(deadline.failure_reason(start + seconds(91)), "download 'stalled' made no byte progress or source change for 45s (attempt 2)")); deadline.observe(R"({"kind":"data","dataKind":"download_progress","payload":{"stream":"install:1","files":[{"name":"stalled","started":true,"finished":true,"sourceAttempt":2,"downloadedBytes":1}]}})", start + seconds(92)); - EXPECT_TRUE(deadline.failure_reason(start + hours(1)).empty()); -} + expect(deadline.failure_reason(start + hours(1)).empty()); +}}; -TEST(NetworkCheck, AQueuedFilesBudgetStartsWhenItBecomesActive) { +UTest a_queued_files_budget_starts_when_it_becomes_active{"NetworkCheck/AQueuedFilesBudgetStartsWhenItBecomesActive", [] { using namespace std::chrono; namespace x = mcpp::provider::xlings; const auto start = steady_clock::now(); @@ -146,22 +146,21 @@ TEST(NetworkCheck, AQueuedFilesBudgetStartsWhenItBecomesActive) { mcpp::provider::BootstrapProgress progress; progress.files.push_back({.name = "queued", .started = false}); watchdog.observe(progress, start); - EXPECT_FALSE(watchdog.expired(start + seconds(96))); + expect(!(watchdog.expired(start + seconds(96)))); // Neither bytes nor sourceAttempt changes when the worker obtains a slot. progress.files.front().started = true; watchdog.observe(progress, start + seconds(96)); - EXPECT_FALSE(watchdog.expired(start + seconds(140))); - EXPECT_EQ(watchdog.failure_reason(start + seconds(141)), - "download 'queued' made no byte progress or source change for 45s (attempt 0)"); + expect(!(watchdog.expired(start + seconds(140)))); + expect(eq(watchdog.failure_reason(start + seconds(141)), "download 'queued' made no byte progress or source change for 45s (attempt 0)")); progress.files.front().sourceAttempt = 1; watchdog.observe(progress, start + seconds(142)); - EXPECT_FALSE(watchdog.expired(start + seconds(186))); + expect(!(watchdog.expired(start + seconds(186)))); progress.files.front().finished = true; watchdog.observe(progress, start + seconds(187)); - EXPECT_FALSE(watchdog.expired(start + hours(1))); -} + expect(!(watchdog.expired(start + hours(1)))); +}}; -TEST(NetworkProcess, ProgressCancellationIsPolledWhileOutputContinues) { +UTest progress_cancellation_is_polled_while_output_continues{"NetworkProcess/ProgressCancellationIsPolledWhileOutputContinues", [] { using namespace std::chrono; const auto start = steady_clock::now(); const auto cmd = mcpplibs::os::is_windows @@ -175,22 +174,22 @@ TEST(NetworkProcess, ProgressCancellationIsPolledWhileOutputContinues) { .on_line = [&](std::string_view) { ++lines; }, .should_stop = [&] { return steady_clock::now() - start >= milliseconds(200); }, .one_string = sh.one_string }).timed_out; - EXPECT_TRUE(stopped); - EXPECT_GT(lines, 0); - EXPECT_LT(steady_clock::now() - start, seconds(3)); -} + expect(bool(stopped)); + expect(gt(lines, 0)); + expect(lt(steady_clock::now() - start, seconds(3))); +}}; -TEST(NetworkCheck, IndexPreparationIsBoundedBeforeTheFirstDownloadEvent) { +UTest index_preparation_is_bounded_before_the_first_download_event{"NetworkCheck/IndexPreparationIsBoundedBeforeTheFirstDownloadEvent", [] { using namespace std::chrono; const auto start = steady_clock::now(); mcpp::provider::xlings::InstallDeadline deadline(seconds(120), seconds(30), start); deadline.observe(R"({"kind":"heartbeat","ts":100})", start + seconds(119)); - EXPECT_TRUE(deadline.expired(start + seconds(120))); + expect(bool(deadline.expired(start + seconds(120)))); deadline.observe(R"({"kind":"progress","phase":"extract","percent":0})", start + seconds(119)); - EXPECT_FALSE(deadline.expired(start + minutes(10))); -} + expect(!(deadline.expired(start + minutes(10)))); +}}; -TEST(NetworkCheck, LegacyStatusAndHandshakeDoNotWriteTheSelectedHome) { +UTest legacy_status_and_handshake_do_not_write_the_selected_home{"NetworkCheck/LegacyStatusAndHandshakeDoNotWriteTheSelectedHome", [] { const auto dir = std::filesystem::temp_directory_path() / std::format("mcpp-legacy-probe-{}", std::chrono::steady_clock::now().time_since_epoch().count()); struct Cleanup { @@ -219,20 +218,20 @@ TEST(NetworkCheck, LegacyStatusAndHandshakeDoNotWriteTheSelectedHome) { std::filesystem::current_path(project); mcpp::provider::Location env{binary, home, {}}; const auto metadata = mcpp::provider::xlings::describe(std::filesystem::relative(binary, project)); - ASSERT_TRUE(metadata.answered) << "the legacy handshake fixture never ran"; - EXPECT_EQ(metadata.version, "9999.1.1.1"); + expect(fatal(bool(metadata.answered))) << "the legacy handshake fixture never ran"; + expect(eq(metadata.version, "9999.1.1.1")); for (const auto* capability : {"system_status", "payload_status"}) { const auto result = mcpp::provider::xlings::call(env, capability, "{}"); - ASSERT_FALSE(result); - EXPECT_NE(result.error().find("read_only_queries"), std::string::npos); + expect(fatal(!result)); + expect(ne(result.error().find("read_only_queries"), std::string::npos)); } mcpp::invocation::ScopedEnv online("MCPP_OFFLINE", "0"); - EXPECT_EQ(mcpp::provider::xlings::network_check(env, std::chrono::seconds(1)).status, "unsupported"); - EXPECT_TRUE(std::filesystem::is_empty(home)) << "legacy metadata or status touched the selected home"; - EXPECT_TRUE(std::filesystem::is_empty(project)) << "legacy metadata touched the current project"; -} + expect(eq(mcpp::provider::xlings::network_check(env, std::chrono::seconds(1)).status, "unsupported")); + expect(std::filesystem::is_empty(home)) << "legacy metadata or status touched the selected home"; + expect(std::filesystem::is_empty(project)) << "legacy metadata touched the current project"; +}}; -TEST(NetworkCheck, BootstrapRequiresDownloadBudgetsAndReadOnlyQueries) { +UTest bootstrap_requires_download_budgets_and_read_only_queries{"NetworkCheck/BootstrapRequiresDownloadBudgetsAndReadOnlyQueries", [] { mcpp::provider::ProviderInfo info; info.answered = true; info.version = "9999.1.1.1"; @@ -240,15 +239,15 @@ TEST(NetworkCheck, BootstrapRequiresDownloadBudgetsAndReadOnlyQueries) { info.features = {"index_dir", "index_store", "upstreams", "payload_status", "sync_index", "payload_currency", "binding_state"}; for (const auto* feature : {"network_diagnostics", "download_socket_budgets", "read_only_queries"}) { - EXPECT_NE(mcpp::provider::xlings::unmet(info).find(feature), std::string::npos); + expect(ne(mcpp::provider::xlings::unmet(info).find(feature), std::string::npos)); info.features.push_back(feature); } - EXPECT_TRUE(mcpp::provider::xlings::unmet(info).empty()); -} + expect(mcpp::provider::xlings::unmet(info).empty()); +}}; -TEST(NetworkProcess, MetadataAndResourceDeadlinesStopDescendants) { - if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "POSIX subprocess fixture"; +UTest metadata_and_resource_deadlines_stop_descendants{"NetworkProcess/MetadataAndResourceDeadlinesStopDescendants", [] { + if constexpr (mcpplibs::os::is_windows) skip("POSIX subprocess fixture"); const std::string writer = R"SH( (file="${0%/*}/ticks"; i=0 while [ "$i" -lt 50 ]; do @@ -266,19 +265,19 @@ wait if (metadata) std::ofstream(f.env.binary) << "#!/bin/sh\n" << writer; const auto start = std::chrono::steady_clock::now(); if (metadata) - EXPECT_FALSE(mcpp::provider::xlings::describe(f.env.binary, std::chrono::milliseconds(500)).answered); + expect(!(mcpp::provider::xlings::describe(f.env.binary, std::chrono::milliseconds(500)).answered)); else { const auto report = mcpp::provider::xlings::network_check(f.env, std::chrono::milliseconds(500)); - ASSERT_EQ(report.endpoints.size(), 1u); - EXPECT_EQ(report.endpoints.front().status, "timeout"); + expect(fatal(eq(report.endpoints.size(), 1u))); + expect(eq(report.endpoints.front().status, "timeout")); } - EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds(2)); + expect(lt(std::chrono::steady_clock::now() - start, std::chrono::seconds(2))); const auto ticks = f.dir / "ticks"; - ASSERT_TRUE(std::filesystem::exists(ticks)) << "the descendant fixture never ran"; + expect(fatal(std::filesystem::exists(ticks))) << "the descendant fixture never ran"; const auto before = std::filesystem::file_size(ticks); - ASSERT_GT(before, 0u); + expect(fatal(gt(before, 0u))); std::this_thread::sleep_for(std::chrono::milliseconds(200)); - EXPECT_EQ(std::filesystem::file_size(ticks), before) + expect(eq(std::filesystem::file_size(ticks), before)) << (metadata ? "metadata" : "resource") << " deadline left a descendant writing"; } -} +}}; diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index bfb793ed7..af4ea11a0 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -1,6 +1,6 @@ -#include import std; +import mcpplibs.mtest; import mcpp.invocation; import mcpp.source_kind; import mcpp.build.compile_commands; @@ -17,6 +17,8 @@ import mcpplibs.os.platform; import mcpp.runtime.search; import mcpp.targetside; +using namespace mcpplibs::mtest; + using namespace mcpp::build; namespace { @@ -77,10 +79,10 @@ BuildPlan minimal_plan() { } // namespace -TEST(NinjaBackend, WindowsDriverLinksRetainTheSelectedTargetForCAndCxx) { +UTest windows_driver_links_retain_the_selected_target_for_c_and_cxx{"NinjaBackend/WindowsDriverLinksRetainTheSelectedTargetForCAndCxx", [] { // This regression covers the Windows-host PE driver link branch. if constexpr (!mcpplibs::os::is_windows) - GTEST_SKIP() << "the PE driver link branch is selected on Windows hosts"; + skip("the PE driver link branch is selected on Windows hosts"); for (const auto arch : {"i686", "x86_64", "aarch64"}) { auto plan = minimal_plan(); plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; @@ -93,12 +95,12 @@ TEST(NinjaBackend, WindowsDriverLinksRetainTheSelectedTargetForCAndCxx) { plan.targetSide.cAbi.interfaceName = "msvc"; plan.targetSide.cxx.interfaceName = "msvc"; auto flags = compute_flags(plan); - EXPECT_NE(flags.ld.find(plan.toolchain.crossTargetFlag), std::string::npos); - EXPECT_NE(flags.ldC.find(plan.toolchain.crossTargetFlag), std::string::npos); + expect(ne(flags.ld.find(plan.toolchain.crossTargetFlag), std::string::npos)); + expect(ne(flags.ldC.find(plan.toolchain.crossTargetFlag), std::string::npos)); } -} +}}; -TEST(NinjaBackend, BitcodeExportToolsComeFromTheSelectedLlvmAndAreQuotedAsWords) { +UTest bitcode_export_tools_come_from_the_selected_llvm_and_are_quoted_as_words{"NinjaBackend/BitcodeExportToolsComeFromTheSelectedLlvmAndAreQuotedAsWords", [] { auto plan = minimal_plan(); plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; plan.toolchain.targetTriple = "x86_64-pc-windows-msvc"; @@ -114,16 +116,16 @@ TEST(NinjaBackend, BitcodeExportToolsComeFromTheSelectedLlvmAndAreQuotedAsWords) auto text = emit_ninja_string(plan); const auto nm = plan.toolchain.binaryPath.parent_path() / (mcpplibs::os::is_windows ? "llvm-nm.exe" : "llvm-nm"); - EXPECT_NE(text.find("--llvm-cxx " + ninja_command_word(plan.toolchain.binaryPath.generic_string())), std::string::npos); - EXPECT_NE(text.find("--llvm-nm " + ninja_command_word(nm.generic_string())), std::string::npos); - EXPECT_NE(text.find("--llvm-target x86_64-pc-windows-msvc"), std::string::npos); - EXPECT_NE(text.find("build bin/probe.def : coff_def obj/probe.o"), std::string::npos); + expect(ne(text.find("--llvm-cxx " + ninja_command_word(plan.toolchain.binaryPath.generic_string())), std::string::npos)); + expect(ne(text.find("--llvm-nm " + ninja_command_word(nm.generic_string())), std::string::npos)); + expect(ne(text.find("--llvm-target x86_64-pc-windows-msvc"), std::string::npos)); + expect(ne(text.find("build bin/probe.def : coff_def obj/probe.o"), std::string::npos)); plan.toolchain.compiler = mcpp::toolchain::CompilerId::MSVC; plan.toolchain.binaryPath = "/native/bin/cl.exe"; - EXPECT_EQ(emit_ninja_string(plan).find("coff_args ="), std::string::npos); -} + expect(eq(emit_ninja_string(plan).find("coff_args ="), std::string::npos)); +}}; -TEST(NinjaBackend, OrdinaryCompilersShareTheDetachedScheduleBound) { +UTest ordinary_compilers_share_the_detached_schedule_bound{"NinjaBackend/OrdinaryCompilersShareTheDetachedScheduleBound", [] { auto plan = minimal_plan(); plan.toolchain.version = "16.1.0"; plan.scheduleTag = "detach-codegen"; @@ -137,22 +139,22 @@ TEST(NinjaBackend, OrdinaryCompilersShareTheDetachedScheduleBound) { const auto text = emit_ninja_string(plan); auto rule = [&](std::string_view name) { const auto start = text.find("rule " + std::string(name) + "\n"); - EXPECT_NE(start, std::string::npos) << name; + expect(ne(start, std::string::npos)) << name; return start == std::string::npos ? std::string{} : text.substr(start, text.find("\n\n", start) - start); }; for (const auto name : {"cxx_module", "cxx_object", "c_object", "asm_object", "asm_object_raw", "nasm_object", "cxx_scan"}) - EXPECT_NE(rule(name).find("__compiler-cap --sem .mcpp-sched --output $out --depfile $out.d -- "), std::string::npos) << name; + expect(ne(rule(name).find("__compiler-cap --sem .mcpp-sched --output $out --depfile $out.d -- "), std::string::npos)) << name; // The generation carries the cap: the graph names no number of compilers, // so it is the same whatever the job count (e2e 861). - EXPECT_EQ(text.find("--cap"), std::string::npos); - EXPECT_EQ(text.find("sched_cap"), std::string::npos); - EXPECT_EQ(rule("cxx_module_bmi").find("__compiler-cap"), std::string::npos) + expect(eq(text.find("--cap"), std::string::npos)); + expect(eq(text.find("sched_cap"), std::string::npos)); + expect(eq(rule("cxx_module_bmi").find("__compiler-cap"), std::string::npos)) << "the BMI supervisor already holds a permit"; - EXPECT_EQ(rule("cxx_module_obj").find("__compiler-cap"), std::string::npos) + expect(eq(rule("cxx_module_obj").find("__compiler-cap"), std::string::npos)) << "a join must not wait for a second permit"; -} +}}; -TEST(NinjaBackend, ObjectiveCSourceUsesCObjectRuleAndCFlags) { +UTest objective_c_source_uses_c_object_rule_and_c_flags{"NinjaBackend/ObjectiveCSourceUsesCObjectRuleAndCFlags", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/cocoa.m", @@ -165,15 +167,15 @@ TEST(NinjaBackend, ObjectiveCSourceUsesCObjectRuleAndCFlags) { auto ninja = emit_ninja_string(plan); - EXPECT_NE(ninja.find("build obj/cocoa.o : c_object src/cocoa.m"), std::string::npos) + expect(ne(ninja.find("build obj/cocoa.o : c_object src/cocoa.m"), std::string::npos)) << ninja; - EXPECT_EQ(ninja.find("build obj/cocoa.o : cxx_object src/cocoa.m"), std::string::npos) + expect(eq(ninja.find("build obj/cocoa.o : cxx_object src/cocoa.m"), std::string::npos)) << ninja; - EXPECT_NE(ninja.find("unit_cflags = -DOBJ_C_BUILD=1"), std::string::npos) + expect(ne(ninja.find("unit_cflags = -DOBJ_C_BUILD=1"), std::string::npos)) << ninja; - EXPECT_EQ(ninja.find("unit_cxxflags = -DWRONG_CXX_FLAG=1"), std::string::npos) + expect(eq(ninja.find("unit_cxxflags = -DWRONG_CXX_FLAG=1"), std::string::npos)) << ninja; -} +}}; // mcpp#235: cxx_module/cxx_object must track header/purview/GMF includes via // a GNU-style depfile on non-MSVC toolchains (this test's plan uses GCC on a @@ -185,7 +187,7 @@ TEST(NinjaBackend, ObjectiveCSourceUsesCObjectRuleAndCFlags) { // GCC's `-fmodules` bolts non-standard "reversed" module rules onto the raw // -MMD output that ninja's depfile loader rejects — see the long comment at // the definition site for the empirically-confirmed failure mode. -TEST(NinjaBackend, CxxModuleAndCxxObjectRulesTrackHeaderDepsViaGccDepfile) { +UTest cxx_module_and_cxx_object_rules_track_header_deps_via_gcc_depfile{"NinjaBackend/CxxModuleAndCxxObjectRulesTrackHeaderDepsViaGccDepfile", [] { // Every host (the 2026-09-28 design, WS2): whether a unit emits a depfile // is the compiler's property. What differs by host is how GCC's filtered // form arrives: the awk program in the POSIX rule's shell command, and @@ -197,32 +199,31 @@ TEST(NinjaBackend, CxxModuleAndCxxObjectRulesTrackHeaderDepsViaGccDepfile) { auto module_rule_start = ninja.find("rule cxx_module"); auto object_rule_start = ninja.find("rule cxx_object"); - ASSERT_NE(module_rule_start, std::string::npos) << ninja; - ASSERT_NE(object_rule_start, std::string::npos) << ninja; - ASSERT_LT(module_rule_start, object_rule_start) << ninja; + expect(fatal(ne(module_rule_start, std::string::npos))) << ninja; + expect(fatal(ne(object_rule_start, std::string::npos))) << ninja; + expect(fatal(lt(module_rule_start, object_rule_start))) << ninja; auto module_rule = ninja.substr(module_rule_start, object_rule_start - module_rule_start); auto object_rule = ninja.substr(object_rule_start); for (auto const& rule : {module_rule, object_rule}) { - EXPECT_NE(rule.find("-MMD -MF $out.d.raw"), std::string::npos) << ninja; - EXPECT_NE(rule.find("deps = gcc"), std::string::npos) << ninja; - EXPECT_NE(rule.find("depfile = $out.d\n"), std::string::npos) << ninja; + expect(ne(rule.find("-MMD -MF $out.d.raw"), std::string::npos)) << ninja; + expect(ne(rule.find("deps = gcc"), std::string::npos)) << ninja; + expect(ne(rule.find("depfile = $out.d\n"), std::string::npos)) << ninja; // The raw compiler depfile (with GCC's module-specific reversed // rules) must never be bound directly as ninja's depfile. - EXPECT_EQ(rule.find("depfile = $out.d.raw"), std::string::npos) << ninja; + expect(eq(rule.find("depfile = $out.d.raw"), std::string::npos)) << ninja; if constexpr (mcpplibs::os::is_windows) { - EXPECT_NE(rule.find("$mcpp depfile-filter --raw $out.d.raw --out $out.d -- "), - std::string::npos) << ninja; - EXPECT_EQ(rule.find("awk"), std::string::npos) << ninja; + expect(ne(rule.find("$mcpp depfile-filter --raw $out.d.raw --out $out.d -- "), std::string::npos)) << ninja; + expect(eq(rule.find("awk"), std::string::npos)) << ninja; } else { - EXPECT_NE(rule.find("awk"), std::string::npos) << ninja; - EXPECT_EQ(rule.find("depfile-filter"), std::string::npos) << ninja; + expect(ne(rule.find("awk"), std::string::npos)) << ninja; + expect(eq(rule.find("depfile-filter"), std::string::npos)) << ninja; } } -} +}}; -TEST(NinjaBackend, UsesPackageCppStandardForCxxFlags) { +UTest uses_package_cpp_standard_for_cxx_flags{"NinjaBackend/UsesPackageCppStandardForCxxFlags", [] { auto plan = minimal_plan(); plan.manifest.package.standard = "c++26"; plan.manifest.language.standard = "c++26"; @@ -237,13 +238,13 @@ TEST(NinjaBackend, UsesPackageCppStandardForCxxFlags) { auto ninja = emit_ninja_string(plan); - EXPECT_NE(ninja.find("cxxflags = -std=c++26"), std::string::npos) + expect(ne(ninja.find("cxxflags = -std=c++26"), std::string::npos)) << ninja; - EXPECT_EQ(ninja.find("-std=c++23"), std::string::npos) + expect(eq(ninja.find("-std=c++23"), std::string::npos)) << ninja; -} +}}; -TEST(NinjaBackend, CompileCommandsUsesSameCppStandard) { +UTest compile_commands_uses_same_cpp_standard{"NinjaBackend/CompileCommandsUsesSameCppStandard", [] { auto plan = minimal_plan(); plan.manifest.package.standard = "c++26"; plan.manifest.language.standard = "c++26"; @@ -259,11 +260,11 @@ TEST(NinjaBackend, CompileCommandsUsesSameCppStandard) { auto flags = compute_flags(plan); auto cdb = emit_compile_commands(plan, flags); - EXPECT_NE(cdb.find("\"-std=c++26\""), std::string::npos) + expect(ne(cdb.find("\"-std=c++26\""), std::string::npos)) << cdb; - EXPECT_EQ(cdb.find("\"-std=c++23\""), std::string::npos) + expect(eq(cdb.find("\"-std=c++23\""), std::string::npos)) << cdb; -} +}}; // #690 (design record F7): the root's `[build] include_dirs` and // `include_dirs_after` are its PRIVATE build requirement. They reach the root's @@ -273,7 +274,7 @@ TEST(NinjaBackend, CompileCommandsUsesSameCppStandard) { // were appended to all four strings, so a root header named like a system or // dependency header shadowed it inside the dependency, and a dependency cache // entry could be compiled against another project's root headers. -TEST(NinjaBackend, FileLevelFlagsCarryNoRootIncludeDirectory) { +UTest file_level_flags_carry_no_root_include_directory{"NinjaBackend/FileLevelFlagsCarryNoRootIncludeDirectory", [] { auto plan = minimal_plan(); plan.nasmPath = "/usr/bin/nasm"; plan.manifest.buildConfig.includeDirs = {"include", "third_party/imgui"}; @@ -283,12 +284,11 @@ TEST(NinjaBackend, FileLevelFlagsCarryNoRootIncludeDirectory) { for (auto const* channel : {&flags.cxx, &flags.cc, &flags.as, &flags.nasm}) { for (auto const* dir : {"include", "imgui", "generated"}) { - EXPECT_EQ(channel->find(dir), std::string::npos) - << "root include directory '" << dir + if (auto&& failed = expect(eq(channel->find(dir), std::string::npos)); !failed) failed << "root include directory '" << dir << "' reached a file-level flag string: " << *channel; } } -} +}}; // What the file-level channel used to guarantee now holds on the per-unit // one: a relative `[build] include_dirs` entry is absolutised against the @@ -296,16 +296,16 @@ TEST(NinjaBackend, FileLevelFlagsCarryNoRootIncludeDirectory) { // directory (#390; the Cluster A review fix of #226/#234, whose failure shape // was a relative `/Iinclude` under the MSVC dialect that stopped resolving // once ninja ran from the output directory). -TEST(NinjaBackend, RootIncludeEntriesAreAbsolutisedForTheUnitChannel) { +UTest root_include_entries_are_absolutised_for_the_unit_channel{"NinjaBackend/RootIncludeEntriesAreAbsolutisedForTheUnitChannel", [] { auto plan = minimal_plan(); auto dirs = mcpp::build::expand_manifest_include_entry( plan.projectRoot, "third_party/imgui"); - ASSERT_EQ(dirs.size(), 1u); + expect(fatal(eq(dirs.size(), 1u))); auto expected = plan.projectRoot / "third_party" / "imgui"; expected.make_preferred(); - EXPECT_EQ(dirs.front(), expected); - EXPECT_TRUE(dirs.front().has_root_path()) << dirs.front(); -} + expect(eq(dirs.front(), expected)); + expect(dirs.front().has_root_path()) << dirs.front(); +}}; // #249: a compile unit's localIncludeDirsAfter emit as -idirafter into the // same $local_includes variable, APPENDED after the -I entries. -idirafter @@ -315,7 +315,7 @@ TEST(NinjaBackend, RootIncludeEntriesAreAbsolutisedForTheUnitChannel) { // while the dep's real headers stay findable. The flag's semantics — not // its position — carry the priority; the -I-then-idirafter ordering is for // readability. -TEST(NinjaBackend, LocalIncludeDirsAfterEmitIdirafterAppendedAfterDashI) { +UTest local_include_dirs_after_emit_idirafter_appended_after_dash_i{"NinjaBackend/LocalIncludeDirsAfterEmitIdirafterAppendedAfterDashI", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", @@ -329,24 +329,24 @@ TEST(NinjaBackend, LocalIncludeDirsAfterEmitIdirafterAppendedAfterDashI) { auto ninja = emit_ninja_string(plan); auto line_start = ninja.find("local_includes ="); - ASSERT_NE(line_start, std::string::npos) << ninja; + expect(fatal(ne(line_start, std::string::npos))) << ninja; auto line_end = ninja.find('\n', line_start); auto line = ninja.substr(line_start, line_end - line_start); auto i_pos = line.find("-I/dep/include"); auto after_pos = line.find("-idirafter/dep/tarball-root"); - ASSERT_NE(i_pos, std::string::npos) << line; - ASSERT_NE(after_pos, std::string::npos) << line; + expect(fatal(ne(i_pos, std::string::npos))) << line; + expect(fatal(ne(after_pos, std::string::npos))) << line; // After-dirs come after the -I entries within $local_includes. - EXPECT_LT(i_pos, after_pos) << line; + expect(lt(i_pos, after_pos)) << line; // Never upgraded to -I. - EXPECT_EQ(line.find("-I/dep/tarball-root"), std::string::npos) << line; -} + expect(eq(line.find("-I/dep/tarball-root"), std::string::npos)) << line; +}}; // #249 MSVC degradation: cl.exe has no -idirafter, so under the msvc // dialect after-dirs are emitted as regular /I at the END of the include // list (clang targeting MSVC uses the gnu dialect and gets -idirafter). -TEST(NinjaBackend, MsvcDialectEmitsIncludeDirsAfterAsTrailingSlashI) { +UTest msvc_dialect_emits_include_dirs_after_as_trailing_slash_i{"NinjaBackend/MsvcDialectEmitsIncludeDirsAfterAsTrailingSlashI", [] { auto plan = minimal_plan(); plan.toolchain.compiler = mcpp::toolchain::CompilerId::MSVC; plan.toolchain.binaryPath = "cl.exe"; @@ -363,7 +363,7 @@ TEST(NinjaBackend, MsvcDialectEmitsIncludeDirsAfterAsTrailingSlashI) { auto ninja = emit_ninja_string(plan); auto line_start = ninja.find("local_includes ="); - ASSERT_NE(line_start, std::string::npos) << ninja; + expect(fatal(ne(line_start, std::string::npos))) << ninja; auto line_end = ninja.find('\n', line_start); auto line = ninja.substr(line_start, line_end - line_start); @@ -373,19 +373,19 @@ TEST(NinjaBackend, MsvcDialectEmitsIncludeDirsAfterAsTrailingSlashI) { // the dialect on one of them. Converging both on include_token fixed it. auto i_pos = line.find("/I/dep/include"); auto after_pos = line.find("/I/dep/tarball-root"); - ASSERT_NE(i_pos, std::string::npos) << line; - ASSERT_NE(after_pos, std::string::npos) << line; - EXPECT_LT(i_pos, after_pos) << line; - EXPECT_EQ(line.find("-idirafter"), std::string::npos) << line; - EXPECT_EQ(line.find("-I/dep"), std::string::npos) << line; -} + expect(fatal(ne(i_pos, std::string::npos))) << line; + expect(fatal(ne(after_pos, std::string::npos))) << line; + expect(lt(i_pos, after_pos)) << line; + expect(eq(line.find("-idirafter"), std::string::npos)) << line; + expect(eq(line.find("-I/dep"), std::string::npos)) << line; +}}; // #331: the per-TU include channel applied only ninja's `$` escaping, while // the global channel in flags.cppm shell-quoted. Same manifest include_dirs, // two derivations — so a directory with a space in it survived one path and // split into separate shell words on the other, which is what every Windows // user hits the moment a dependency lands under `C:\Program Files`. -TEST(NinjaBackend, LocalIncludeDirsWithSpacesAreShellQuoted) { +UTest local_include_dirs_with_spaces_are_shell_quoted{"NinjaBackend/LocalIncludeDirsWithSpacesAreShellQuoted", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", @@ -398,7 +398,7 @@ TEST(NinjaBackend, LocalIncludeDirsWithSpacesAreShellQuoted) { auto ninja = emit_ninja_string(plan); auto line_start = ninja.find("local_includes ="); - ASSERT_NE(line_start, std::string::npos) << ninja; + expect(fatal(ne(line_start, std::string::npos))) << ninja; auto line = ninja.substr(line_start, ninja.find('\n', line_start) - line_start); // Two escaping layers, in order: ninja's (`$ ` for a literal space, so @@ -412,9 +412,9 @@ TEST(NinjaBackend, LocalIncludeDirsWithSpacesAreShellQuoted) { // wants single quotes, cmd.exe double. Hardcoding `'` passed on Linux // and failed on the Windows runner for a difference that is correct. const std::string q = mcpplibs::os::is_windows ? "\"" : "'"; - EXPECT_NE(line.find(q + "-I/opt/my$ dep/include" + q), std::string::npos) << line; - EXPECT_NE(line.find(q + "-idirafter/opt/my$ dep/after" + q), std::string::npos) << line; -} + expect(ne(line.find(q + "-I/opt/my$ dep/include" + q), std::string::npos)) << line; + expect(ne(line.find(q + "-idirafter/opt/my$ dep/after" + q), std::string::npos)) << line; +}}; // #261: on Windows $local_includes is copied into a RESPONSE FILE, which the // drivers tokenize GNU-style — a backslash is an escape character there, and @@ -427,19 +427,19 @@ TEST(NinjaBackend, LocalIncludeDirsWithSpacesAreShellQuoted) { // assertion cannot be made from a Linux host. Guarded rather than weakened — // a test that passes for the wrong reason everywhere is worse than one that // says where it applies. Windows CI is the enforcement point. -TEST(NinjaBackend, LocalIncludeTokensUseGenericSeparators) { +UTest local_include_tokens_use_generic_separators{"NinjaBackend/LocalIncludeTokensUseGenericSeparators", [] { const auto& gnu = mcpp::toolchain::gnu_dialect(); auto sub = std::filesystem::path("src") / "inc"; auto tok = mcpp::build::include_token(gnu, sub, {}, mcpp::build::PathForm::Generic); // True on every host: the generic form never uses the native separator. - EXPECT_NE(tok.find("src/inc"), std::string::npos) << tok; + expect(ne(tok.find("src/inc"), std::string::npos)) << tok; if constexpr (mcpplibs::os::is_windows) { auto abs = mcpp::build::include_token( gnu, std::filesystem::path("C:\\src\\inc"), {}, mcpp::build::PathForm::Generic); - EXPECT_EQ(abs.find('\\'), std::string::npos) << abs; + expect(eq(abs.find('\\'), std::string::npos)) << abs; // The command-line channel keeps native separators — a backslash is // just a character there, and rewriting those paths would be a change @@ -447,15 +447,15 @@ TEST(NinjaBackend, LocalIncludeTokensUseGenericSeparators) { auto native = mcpp::build::include_token( gnu, std::filesystem::path("C:\\src\\inc"), {}, mcpp::build::PathForm::Native); - EXPECT_NE(native.find('\\'), std::string::npos) << native; + expect(ne(native.find('\\'), std::string::npos)) << native; } -} +}}; // #249 NASM degradation: nasm_object edges share $local_includes, but NASM // would parse `-idirafter

` as its `-i` option with value `dirafter

` — // a silently wrong search dir. Only the C/C++ frontends have a system-header // chain to protect, so a nasm unit's after-dirs degrade to plain -I. -TEST(NinjaBackend, NasmUnitEmitsIncludeDirsAfterAsPlainDashI) { +UTest nasm_unit_emits_include_dirs_after_as_plain_dash_i{"NinjaBackend/NasmUnitEmitsIncludeDirsAfterAsPlainDashI", [] { auto plan = minimal_plan(); plan.nasmPath = "/usr/bin/nasm"; plan.compileUnits.push_back({ @@ -470,19 +470,19 @@ TEST(NinjaBackend, NasmUnitEmitsIncludeDirsAfterAsPlainDashI) { auto ninja = emit_ninja_string(plan); auto edge_start = ninja.find("build obj/scale.asm.o : nasm_object"); - ASSERT_NE(edge_start, std::string::npos) << ninja; + expect(fatal(ne(edge_start, std::string::npos))) << ninja; auto line_start = ninja.find("local_includes =", edge_start); - ASSERT_NE(line_start, std::string::npos) << ninja; + expect(fatal(ne(line_start, std::string::npos))) << ninja; auto line = ninja.substr(line_start, ninja.find('\n', line_start) - line_start); - EXPECT_NE(line.find("-I/dep/x86"), std::string::npos) << line; - EXPECT_NE(line.find("-I/dep/tarball-root"), std::string::npos) << line; - EXPECT_EQ(line.find("-idirafter"), std::string::npos) << line; -} + expect(ne(line.find("-I/dep/x86"), std::string::npos)) << line; + expect(ne(line.find("-I/dep/tarball-root"), std::string::npos)) << line; + expect(eq(line.find("-idirafter"), std::string::npos)) << line; +}}; // ── assembly sources (.S/.s → asm_object via $cc, .asm → nasm_object) ──────── -TEST(NinjaBackend, GasSourceUsesAsmObjectRule) { +UTest gas_source_uses_asm_object_rule{"NinjaBackend/GasSourceUsesAsmObjectRule", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/copy.S", @@ -497,23 +497,22 @@ TEST(NinjaBackend, GasSourceUsesAsmObjectRule) { auto ninja = emit_ninja_string(plan); - EXPECT_NE(ninja.find("rule asm_object"), std::string::npos) << ninja; - EXPECT_NE(ninja.find("build obj/copy.S.o : asm_object src/copy.S"), - std::string::npos) << ninja; - EXPECT_NE(ninja.find("cc = "), std::string::npos) << ninja; - EXPECT_NE(ninja.find("unit_asmflags = -DHAVE_ASM=1\n"), std::string::npos) + expect(ne(ninja.find("rule asm_object"), std::string::npos)) << ninja; + expect(ne(ninja.find("build obj/copy.S.o : asm_object src/copy.S"), std::string::npos)) << ninja; + expect(ne(ninja.find("cc = "), std::string::npos)) << ninja; + expect(ne(ninja.find("unit_asmflags = -DHAVE_ASM=1\n"), std::string::npos)) << ninja; - EXPECT_EQ(ninja.find("unit_asmflags = -DHAVE_ASM=1 -std=c99"), std::string::npos) + expect(eq(ninja.find("unit_asmflags = -DHAVE_ASM=1 -std=c99"), std::string::npos)) << ninja; // The global asm flag string must not carry a C standard or opt level. auto asmline_pos = ninja.find("asmflags ="); - ASSERT_NE(asmline_pos, std::string::npos) << ninja; + expect(fatal(ne(asmline_pos, std::string::npos))) << ninja; auto asmline = ninja.substr(asmline_pos, ninja.find('\n', asmline_pos) - asmline_pos); - EXPECT_EQ(asmline.find("-std="), std::string::npos) << asmline; - EXPECT_EQ(asmline.find("-O"), std::string::npos) << asmline; -} + expect(eq(asmline.find("-std="), std::string::npos)) << asmline; + expect(eq(asmline.find("-O"), std::string::npos)) << asmline; +}}; -TEST(NinjaBackend, NasmSourceUsesNasmRuleWithDerivedFormat) { +UTest nasm_source_uses_nasm_rule_with_derived_format{"NinjaBackend/NasmSourceUsesNasmRuleWithDerivedFormat", [] { auto plan = minimal_plan(); plan.nasmPath = "/opt/bin/nasm"; plan.nasmFormat = "elf64"; @@ -527,16 +526,15 @@ TEST(NinjaBackend, NasmSourceUsesNasmRuleWithDerivedFormat) { auto ninja = emit_ninja_string(plan); - EXPECT_NE(ninja.find("rule nasm_object"), std::string::npos) << ninja; - EXPECT_NE(ninja.find("nasm = /opt/bin/nasm"), std::string::npos) << ninja; - EXPECT_NE(ninja.find("nasmfmt = elf64"), std::string::npos) << ninja; - EXPECT_NE(ninja.find("build obj/simd.asm.o : nasm_object src/simd.asm"), - std::string::npos) << ninja; - EXPECT_NE(ninja.find("unit_asmflags = -DHAVE_AVX2=1\n"), std::string::npos) + expect(ne(ninja.find("rule nasm_object"), std::string::npos)) << ninja; + expect(ne(ninja.find("nasm = /opt/bin/nasm"), std::string::npos)) << ninja; + expect(ne(ninja.find("nasmfmt = elf64"), std::string::npos)) << ninja; + expect(ne(ninja.find("build obj/simd.asm.o : nasm_object src/simd.asm"), std::string::npos)) << ninja; + expect(ne(ninja.find("unit_asmflags = -DHAVE_AVX2=1\n"), std::string::npos)) << ninja; -} +}}; -TEST(NinjaBackend, NoAsmRulesWithoutAsmSources) { +UTest no_asm_rules_without_asm_sources{"NinjaBackend/NoAsmRulesWithoutAsmSources", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", @@ -547,11 +545,11 @@ TEST(NinjaBackend, NoAsmRulesWithoutAsmSources) { auto ninja = emit_ninja_string(plan); - EXPECT_EQ(ninja.find("rule asm_object"), std::string::npos) << ninja; - EXPECT_EQ(ninja.find("rule nasm_object"), std::string::npos) << ninja; -} + expect(eq(ninja.find("rule asm_object"), std::string::npos)) << ninja; + expect(eq(ninja.find("rule nasm_object"), std::string::npos)) << ninja; +}}; -TEST(NinjaBackend, CompileCommandsSkipNasmAndCoverGas) { +UTest compile_commands_skip_nasm_and_cover_gas{"NinjaBackend/CompileCommandsSkipNasmAndCoverGas", [] { auto plan = minimal_plan(); plan.nasmPath = "/opt/bin/nasm"; plan.nasmFormat = "elf64"; @@ -572,11 +570,11 @@ TEST(NinjaBackend, CompileCommandsSkipNasmAndCoverGas) { auto cdb = emit_compile_commands(plan, flags); // NASM command lines are meaningless to CDB consumers (clangd) — excluded. - EXPECT_EQ(cdb.find("simd.asm"), std::string::npos) << cdb; + expect(eq(cdb.find("simd.asm"), std::string::npos)) << cdb; // GAS units ride the C driver and stay in the CDB. - EXPECT_NE(cdb.find("copy.S"), std::string::npos) << cdb; - EXPECT_EQ(cdb.find("\"-std=c11\""), std::string::npos) << cdb; // asm-safe flags, no C std -} + expect(ne(cdb.find("copy.S"), std::string::npos)) << cdb; + expect(eq(cdb.find("\"-std=c11\""), std::string::npos)) << cdb; // asm-safe flags, no C std +}}; // mcpp#234: each packageCflags/packageCxxflags element is already one argv // token — apply_glob_flags pushes a define like `T=long long` as the single @@ -585,7 +583,7 @@ TEST(NinjaBackend, CompileCommandsSkipNasmAndCoverGas) { // it to the shell, the embedded space split `-DT=long long` into TWO words // (`-DT=long` and a bare `long`). The emitted unit_cflags line must carry the // define as a single shell-quoted token. -TEST(NinjaBackend, QuotesFlagValueWithSpace) { +UTest quotes_flag_value_with_space{"NinjaBackend/QuotesFlagValueWithSpace", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.c", @@ -602,15 +600,15 @@ TEST(NinjaBackend, QuotesFlagValueWithSpace) { const std::string quoted = mcpplibs::os::is_windows ? "unit_cflags = \"-DT=long long\"" : "unit_cflags = '-DT=long long'"; - EXPECT_NE(ninja.find(quoted), std::string::npos) << ninja; + expect(ne(ninja.find(quoted), std::string::npos)) << ninja; // Must NOT appear as two bare, unquoted words split on the space. - EXPECT_EQ(ninja.find("unit_cflags = -DT=long long"), std::string::npos) + expect(eq(ninja.find("unit_cflags = -DT=long long"), std::string::npos)) << ninja; -} +}}; // Plain framework-shaped flags with nothing shell-significant must pass // through byte-for-byte unquoted (no over-quoting regression). -TEST(NinjaBackend, PlainFlagsPassThroughUnquoted) { +UTest plain_flags_pass_through_unquoted{"NinjaBackend/PlainFlagsPassThroughUnquoted", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", @@ -622,9 +620,9 @@ TEST(NinjaBackend, PlainFlagsPassThroughUnquoted) { auto ninja = emit_ninja_string(plan); - EXPECT_NE(ninja.find("unit_cxxflags = -DFOO=1 -O2"), std::string::npos) + expect(ne(ninja.find("unit_cxxflags = -DFOO=1 -O2"), std::string::npos)) << ninja; -} +}}; // The SubOS farm must reach DT_RPATH AFTER the artifact's own directory. // @@ -637,9 +635,9 @@ TEST(NinjaBackend, PlainFlagsPassThroughUnquoted) { // The assertion is on the EFFECTIVE line (global then unit, exactly as the // rule expands it), not on either variable alone: checking one of them is what // made the original mistake invisible. -TEST(NinjaBackend, SubosFarmRpathFollowsTheArtifactsOwnDirectory) { +UTest subos_farm_rpath_follows_the_artifacts_own_directory{"NinjaBackend/SubosFarmRpathFollowsTheArtifactsOwnDirectory", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "the SubOS farm rpath is emitted for ELF targets only"; + skip("the SubOS farm rpath is emitted for ELF targets only"); const std::string farm = "/tmp/mcpp-ninja-test-farm/subos/default/lib"; auto plan = minimal_plan(); @@ -675,22 +673,22 @@ TEST(NinjaBackend, SubosFarmRpathFollowsTheArtifactsOwnDirectory) { auto origin = effective.find("$$ORIGIN"); auto fallback = effective.find(farm); - ASSERT_NE(origin, std::string::npos) << effective; - ASSERT_NE(fallback, std::string::npos) << effective; - EXPECT_LT(origin, fallback) + expect(fatal(ne(origin, std::string::npos))) << effective; + expect(fatal(ne(fallback, std::string::npos))) << effective; + expect(lt(origin, fallback)) << "the SubOS farm outranks $ORIGIN, so the artifact can load a " "different build of a library than it linked against:\n" << effective; // And it must have left the global channel entirely — leaving a copy there // would restore the old order no matter what the unit tail says. - EXPECT_EQ(line_after("\nldflags =").find(farm), std::string::npos) << ninja; -} + expect(eq(line_after("\nldflags =").find(farm), std::string::npos)) << ninja; +}}; // An archive is produced by `ar`, whose rule never expands `$unit_ldflags`. // Emitting run-time search flags there would be dead bytes in every graph. -TEST(NinjaBackend, StaticLibraryCarriesNoRuntimeFallback) { +UTest static_library_carries_no_runtime_fallback{"NinjaBackend/StaticLibraryCarriesNoRuntimeFallback", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "the SubOS farm rpath is emitted for ELF targets only"; + skip("the SubOS farm rpath is emitted for ELF targets only"); const std::string farm = "/tmp/mcpp-ninja-test-farm/subos/default/lib"; auto plan = minimal_plan(); @@ -711,10 +709,10 @@ TEST(NinjaBackend, StaticLibraryCarriesNoRuntimeFallback) { auto ninja = emit_ninja_string(plan); auto at = ninja.find("build lib/libgreet.a"); - ASSERT_NE(at, std::string::npos) << ninja; + expect(fatal(ne(at, std::string::npos))) << ninja; auto stanza = ninja.substr(at, ninja.find("\nbuild ", at + 1) - at); - EXPECT_EQ(stanza.find(farm), std::string::npos) << stanza; -} + expect(eq(stanza.find(farm), std::string::npos)) << stanza; +}}; // Regression: mcpp-GENERATED per-unit LINK flags are already correctly // shell-quoted + ninja-escaped at construction — e.g. the shared-dep rpath @@ -723,7 +721,7 @@ TEST(NinjaBackend, StaticLibraryCarriesNoRuntimeFallback) { // shell_quote_arg over them: doing so double-quotes the token, baking a // literal `'$ORIGIN'` (quotes included) into the binary's RUNPATH so the // dynamic linker can't find dependency .so's next to the exe (e2e 55-57/64). -TEST(NinjaBackend, LinkFlagsAreNotReQuoted) { +UTest link_flags_are_not_re_quoted{"NinjaBackend/LinkFlagsAreNotReQuoted", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", @@ -743,11 +741,11 @@ TEST(NinjaBackend, LinkFlagsAreNotReQuoted) { auto ninja = emit_ninja_string(plan); // The rpath token passes through verbatim (ninja `$$` = literal `$`). - EXPECT_NE(ninja.find("-Wl,-rpath,'$$ORIGIN'"), std::string::npos) << ninja; + expect(ne(ninja.find("-Wl,-rpath,'$$ORIGIN'"), std::string::npos)) << ninja; // Must NOT be double-quoted: shell_quote_arg escaping an embedded `'` // produces the `'\''` sequence, which only appears if it re-quoted. - EXPECT_EQ(ninja.find("'\\''"), std::string::npos) << ninja; -} + expect(eq(ninja.find("'\\''"), std::string::npos)) << ninja; +}}; // Regression (#234 follow-up): a raw descriptor flag that packs two argv // tokens into one string — e.g. compat.lua's `-include

` — must pass @@ -756,7 +754,7 @@ TEST(NinjaBackend, LinkFlagsAreNotReQuoted) { // file literally named "header" → "No such file" → aarch64/macos/windows // cross-builds failed. Only `-D`/`/D` define tokens with an intra-value space // get quoted; `-include foo.h` does not. -TEST(NinjaBackend, RawMultiTokenFlagIsNotQuoted) { +UTest raw_multi_token_flag_is_not_quoted{"NinjaBackend/RawMultiTokenFlagIsNotQuoted", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.c", @@ -769,12 +767,10 @@ TEST(NinjaBackend, RawMultiTokenFlagIsNotQuoted) { auto ninja = emit_ninja_string(plan); // Verbatim, so the shell re-splits into two args. - EXPECT_NE(ninja.find("unit_cflags = -include mcpp_lua_platform_config.h"), - std::string::npos) << ninja; + expect(ne(ninja.find("unit_cflags = -include mcpp_lua_platform_config.h"), std::string::npos)) << ninja; // Must NOT be wrapped in quotes (which would make it one malformed arg). - EXPECT_EQ(ninja.find("'-include mcpp_lua_platform_config.h'"), - std::string::npos) << ninja; -} + expect(eq(ninja.find("'-include mcpp_lua_platform_config.h'"), std::string::npos)) << ninja; +}}; // mcpp#247 + #344: link/archive/shared route the object list through a response // file on EVERY platform. The number of objects on one link edge is unbounded — @@ -792,7 +788,7 @@ TEST(NinjaBackend, RawMultiTokenFlagIsNotQuoted) { // object directories took it to 161 687 — at which point ninja dies with // `posix_spawn: Argument list too long`, naming no edge and no cause. A ceiling // nothing watches is not a ceiling anyone can stay under, so there isn't one now. -TEST(NinjaBackend, DriverStyleLinkRulesAlwaysUseRspfile) { +UTest driver_style_link_rules_always_use_rspfile{"NinjaBackend/DriverStyleLinkRulesAlwaysUseRspfile", [] { auto plan = minimal_plan(); // GCC → gnu dialect → driver-style branch auto ninja = emit_ninja_string(plan); @@ -800,20 +796,20 @@ TEST(NinjaBackend, DriverStyleLinkRulesAlwaysUseRspfile) { for (std::string_view rule : {"rule cxx_link\n", "rule cxx_archive\n", "rule cxx_shared\n"}) { auto start = ninja.find(rule); - ASSERT_NE(start, std::string::npos) << ninja; + expect(fatal(ne(start, std::string::npos))) << ninja; auto end = ninja.find("\n\n", start); - ASSERT_NE(end, std::string::npos) << ninja; + expect(fatal(ne(end, std::string::npos))) << ninja; auto body = ninja.substr(start, end - start); - EXPECT_NE(body.find("@$out.rsp"), std::string::npos) << body; - EXPECT_NE(body.find("rspfile = $out.rsp"), std::string::npos) << body; - EXPECT_NE(body.find("rspfile_content = $in"), std::string::npos) << body; + expect(ne(body.find("@$out.rsp"), std::string::npos)) << body; + expect(ne(body.find("rspfile = $out.rsp"), std::string::npos)) << body; + expect(ne(body.find("rspfile_content = $in"), std::string::npos)) << body; // And the object list must no longer be inlined into the command: that // is the whole point, so `$in` may appear only as rspfile_content. - EXPECT_EQ(count_occurrences(body, "$in"), 1u) << body; + expect(eq(count_occurrences(body, "$in"), 1u)) << body; } -} +}}; -TEST(NinjaBackend, RootPackageCxxflagsAreEmittedOncePerUnit) { +UTest root_package_cxxflags_are_emitted_once_per_unit{"NinjaBackend/RootPackageCxxflagsAreEmittedOncePerUnit", [] { auto plan = minimal_plan(); plan.manifest.buildConfig.cxxflags = {"-DROOT_FLAG=1"}; plan.compileUnits.push_back({ @@ -826,18 +822,18 @@ TEST(NinjaBackend, RootPackageCxxflagsAreEmittedOncePerUnit) { auto ninja = emit_ninja_string(plan); - EXPECT_EQ(count_occurrences(ninja, "unit_cxxflags = -DROOT_FLAG=1"), 2u) + expect(eq(count_occurrences(ninja, "unit_cxxflags = -DROOT_FLAG=1"), 2u)) << ninja; - EXPECT_EQ(ninja.find("cxxflags = -std=c++23 -O2 -DROOT_FLAG=1"), std::string::npos) + expect(eq(ninja.find("cxxflags = -std=c++23 -O2 -DROOT_FLAG=1"), std::string::npos)) << ninja; -} +}}; // mcpp#261: the clang scan rule used shell redirection (`> $out`), which on // Windows forced a `cmd /c` wrapper and with it cmd.exe's 8191-char command // line ceiling — a quarter of what ninja's CreateProcess path allows. Any // package with a large include-dir list overran it. clang-scan-deps has -o // (LLVM 17+), so the redirect and the wrapper are both unnecessary. -TEST(NinjaBackend, ClangScanRuleWritesViaDashOWithoutShellRedirection) { +UTest clang_scan_rule_writes_via_dash_o_without_shell_redirection{"NinjaBackend/ClangScanRuleWritesViaDashOWithoutShellRedirection", [] { auto plan = minimal_plan(); plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; plan.toolchain.binaryPath = "/usr/bin/clang++"; @@ -853,19 +849,19 @@ TEST(NinjaBackend, ClangScanRuleWritesViaDashOWithoutShellRedirection) { auto ninja = emit_ninja_string(plan); auto scanRule = ninja.find("rule cxx_scan"); - ASSERT_NE(scanRule, std::string::npos) << ninja; + expect(fatal(ne(scanRule, std::string::npos))) << ninja; auto scanEnd = ninja.find("description = SCAN", scanRule); - ASSERT_NE(scanEnd, std::string::npos) << ninja; + expect(fatal(ne(scanEnd, std::string::npos))) << ninja; auto rule = ninja.substr(scanRule, scanEnd - scanRule); - EXPECT_NE(rule.find("-format=p1689 -o $out --"), std::string::npos) << rule; - EXPECT_EQ(rule.find("> $out"), std::string::npos) + expect(ne(rule.find("-format=p1689 -o $out --"), std::string::npos)) << rule; + expect(eq(rule.find("> $out"), std::string::npos)) << "scan rule must not use shell redirection: " << rule; -} +}}; // The `cmd /c` wrapper was the only place mcpp put a shell between ninja and // a compiler invocation. Keep it gone: it is what re-imposes the 8191 ceiling. -TEST(NinjaBackend, NoRuleWrapsItsCommandInCmdSlashC) { +UTest no_rule_wraps_its_command_in_cmd_slash_c{"NinjaBackend/NoRuleWrapsItsCommandInCmdSlashC", [] { for (auto compiler : {mcpp::toolchain::CompilerId::GCC, mcpp::toolchain::CompilerId::Clang}) { auto plan = minimal_plan(); @@ -883,10 +879,10 @@ TEST(NinjaBackend, NoRuleWrapsItsCommandInCmdSlashC) { }); auto ninja = emit_ninja_string(plan); - EXPECT_EQ(ninja.find("cmd /c"), std::string::npos) + expect(eq(ninja.find("cmd /c"), std::string::npos)) << "compiler=" << static_cast(compiler) << "\n" << ninja; } -} +}}; // mcpp#261: the compile and scan rules carry an UNBOUNDED flag payload — // one -I per dependency include dir — and on Windows ninja spawns through @@ -895,7 +891,7 @@ TEST(NinjaBackend, NoRuleWrapsItsCommandInCmdSlashC) { // reason. Reachable from a POSIX test host via the msvc dialect, which only // ever runs on Windows. NASM is excluded on purpose (it spells response // files `-@ file`), and POSIX keeps the inline form byte-identical. -TEST(NinjaBackend, CompileAndScanRulesRouteFlagsThroughRspfileUnderMsvcDialect) { +UTest compile_and_scan_rules_route_flags_through_rspfile_under_msvc_dialect{"NinjaBackend/CompileAndScanRulesRouteFlagsThroughRspfileUnderMsvcDialect", [] { auto plan = minimal_plan(); plan.toolchain.compiler = mcpp::toolchain::CompilerId::MSVC; plan.toolchain.binaryPath = "cl.exe"; @@ -926,13 +922,13 @@ TEST(NinjaBackend, CompileAndScanRulesRouteFlagsThroughRspfileUnderMsvcDialect) } for (std::string_view rule : rules) { auto start = ninja.find(rule); - ASSERT_NE(start, std::string::npos) << rule << "\n" << ninja; + expect(fatal(ne(start, std::string::npos))) << rule << "\n" << ninja; auto end = ninja.find("\n\n", start); - ASSERT_NE(end, std::string::npos) << ninja; + expect(fatal(ne(end, std::string::npos))) << ninja; auto body = ninja.substr(start, end - start); - EXPECT_NE(body.find("@$out.rsp"), std::string::npos) << body; - EXPECT_NE(body.find("rspfile = $out.rsp"), std::string::npos) << body; + expect(ne(body.find("@$out.rsp"), std::string::npos)) << body; + expect(ne(body.find("rspfile = $out.rsp"), std::string::npos)) << body; // ONLY $local_includes may live in the response file: its content // is tokenized GNU-style (backslash = escape), and those are the // only paths this file forward-slashes itself. $cxxflags carries @@ -940,27 +936,26 @@ TEST(NinjaBackend, CompileAndScanRulesRouteFlagsThroughRspfileUnderMsvcDialect) // routing it through the rsp ate the separators of the std.pcm path // and broke every `import std;` on Windows. The byte order mark in // front is how cl.exe learns the file is UTF-8 (#693). - EXPECT_NE(body.find("rspfile_content = \xEF\xBB\xBF $local_includes\n"), - std::string::npos) << body; + expect(ne(body.find("rspfile_content = \xEF\xBB\xBF $local_includes\n"), std::string::npos)) << body; // The payload must not ALSO remain inline, or the ceiling stands. auto cmdStart = body.find("command = "); auto cmdEnd = body.find('\n', cmdStart); auto cmd = body.substr(cmdStart, cmdEnd - cmdStart); - EXPECT_EQ(cmd.find("$local_includes"), std::string::npos) << cmd; + expect(eq(cmd.find("$local_includes"), std::string::npos)) << cmd; // ...and the flags that must NOT move off the command line stay there. if (rule != "rule c_object\n") - EXPECT_NE(cmd.find("$cxxflags"), std::string::npos) << cmd; + expect(ne(cmd.find("$cxxflags"), std::string::npos)) << cmd; else - EXPECT_NE(cmd.find("$cflags"), std::string::npos) << cmd; + expect(ne(cmd.find("$cflags"), std::string::npos)) << cmd; } -} +}}; // POSIX must keep the inline form: ARG_MAX is ample and an inline command is // far easier to re-run by hand. This is the byte-identity guard for the // #261 change on the platform where it must be a no-op. -TEST(NinjaBackend, CompileRulesStayInlineOnPosixDrivers) { +UTest compile_rules_stay_inline_on_posix_drivers{"NinjaBackend/CompileRulesStayInlineOnPosixDrivers", [] { if constexpr (mcpplibs::os::is_windows) { - GTEST_SKIP() << "inline form is Windows-exempt by design"; + skip("inline form is Windows-exempt by design"); } else { auto plan = minimal_plan(); // GCC → gnu dialect, non-msvc deps plan.compileUnits.push_back({ @@ -975,22 +970,22 @@ TEST(NinjaBackend, CompileRulesStayInlineOnPosixDrivers) { for (std::string_view rule : {"rule cxx_module\n", "rule cxx_object\n", "rule c_object\n"}) { auto start = ninja.find(rule); - ASSERT_NE(start, std::string::npos) << rule << "\n" << ninja; + expect(fatal(ne(start, std::string::npos))) << rule << "\n" << ninja; auto end = ninja.find("\n\n", start); - ASSERT_NE(end, std::string::npos) << ninja; + expect(fatal(ne(end, std::string::npos))) << ninja; auto body = ninja.substr(start, end - start); - EXPECT_EQ(body.find("rspfile"), std::string::npos) << body; - EXPECT_NE(body.find("$local_includes"), std::string::npos) << body; + expect(eq(body.find("rspfile"), std::string::npos)) << body; + expect(ne(body.find("$local_includes"), std::string::npos)) << body; } } -} +}}; // mcpp#257: "emit a depfile" and "strip GCC's reversed module rules" are two // decisions; 0.0.97 conflated them and left Clang with no include tracking // at all. Clang emits a single plain make rule (measured on 20.1.7/22.1.8), // so it takes the depfile WITHOUT the awk filter, writing -MF straight to // $out.d. -TEST(NinjaBackend, ClangGetsDepfileWithoutTheGccModuleRuleFilter) { +UTest clang_gets_depfile_without_the_gcc_module_rule_filter{"NinjaBackend/ClangGetsDepfileWithoutTheGccModuleRuleFilter", [] { // Every host: the clang++ row on Windows (the default Windows row since // #718) had no depfile until 2026.9.28.2 (WS2). auto plan = minimal_plan(); @@ -1001,23 +996,23 @@ TEST(NinjaBackend, ClangGetsDepfileWithoutTheGccModuleRuleFilter) { auto module_rule_start = ninja.find("rule cxx_module"); auto object_rule_start = ninja.find("rule cxx_object"); - ASSERT_NE(module_rule_start, std::string::npos) << ninja; - ASSERT_NE(object_rule_start, std::string::npos) << ninja; + expect(fatal(ne(module_rule_start, std::string::npos))) << ninja; + expect(fatal(ne(object_rule_start, std::string::npos))) << ninja; auto module_rule = ninja.substr(module_rule_start, object_rule_start - module_rule_start); - EXPECT_NE(module_rule.find("-MMD -MF $out.d"), std::string::npos) << ninja; - EXPECT_NE(module_rule.find("deps = gcc"), std::string::npos) << ninja; - EXPECT_NE(module_rule.find("depfile = $out.d\n"), std::string::npos) << ninja; + expect(ne(module_rule.find("-MMD -MF $out.d"), std::string::npos)) << ninja; + expect(ne(module_rule.find("deps = gcc"), std::string::npos)) << ninja; + expect(ne(module_rule.find("depfile = $out.d\n"), std::string::npos)) << ninja; // No scratch file and no filter: there is nothing to strip. - EXPECT_EQ(module_rule.find("$out.d.raw"), std::string::npos) << ninja; - EXPECT_EQ(module_rule.find("awk"), std::string::npos) << ninja; - EXPECT_EQ(module_rule.find("depfile-filter"), std::string::npos) << ninja; -} + expect(eq(module_rule.find("$out.d.raw"), std::string::npos)) << ninja; + expect(eq(module_rule.find("awk"), std::string::npos)) << ninja; + expect(eq(module_rule.find("depfile-filter"), std::string::npos)) << ninja; +}}; // The other half of the same asymmetry: C and GAS edges include headers too // and had no depfile on ANY toolchain. -TEST(NinjaBackend, CAndAsmRulesAlsoTrackHeaderDeps) { +UTest c_and_asm_rules_also_track_header_deps{"NinjaBackend/CAndAsmRulesAlsoTrackHeaderDeps", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/a.c", @@ -1038,28 +1033,28 @@ TEST(NinjaBackend, CAndAsmRulesAlsoTrackHeaderDeps) { // NOTE: asm_object is the `.S` rule. `.s` uses asm_object_raw and // deliberately has no depfile — see below. auto start = ninja.find(rule); - ASSERT_NE(start, std::string::npos) << rule << "\n" << ninja; + expect(fatal(ne(start, std::string::npos))) << rule << "\n" << ninja; auto end = ninja.find("\n\n", start); - ASSERT_NE(end, std::string::npos) << ninja; + expect(fatal(ne(end, std::string::npos))) << ninja; auto body = ninja.substr(start, end - start); - EXPECT_NE(body.find("-MMD -MF $out.d"), std::string::npos) << body; - EXPECT_NE(body.find("deps = gcc"), std::string::npos) << body; - EXPECT_NE(body.find("depfile = $out.d"), std::string::npos) << body; + expect(ne(body.find("-MMD -MF $out.d"), std::string::npos)) << body; + expect(ne(body.find("deps = gcc"), std::string::npos)) << body; + expect(ne(body.find("depfile = $out.d"), std::string::npos)) << body; // C/GAS units never carry module reversed-rules — no filter, and so // no scratch file to bind by mistake. - EXPECT_EQ(body.find("$out.d.raw"), std::string::npos) << body; - EXPECT_EQ(body.find("awk"), std::string::npos) << body; + expect(eq(body.find("$out.d.raw"), std::string::npos)) << body; + expect(eq(body.find("awk"), std::string::npos)) << body; } -} +}}; // The C driver preprocesses `.S` but not `.s`, so only `.S` can produce a // depfile. Asking anyway makes clang emit "argument unused during // compilation: '-MMD'" for every such file and write nothing, and ninja's // `deps = gcc` treats an absent depfile as an error — so the two cases need // separate rules. -TEST(NinjaBackend, LowercaseAsmHasNoDepfileAndItsOwnRule) { +UTest lowercase_asm_has_no_depfile_and_its_own_rule{"NinjaBackend/LowercaseAsmHasNoDepfileAndItsOwnRule", [] { if constexpr (mcpplibs::os::is_windows) - GTEST_SKIP() << "POSIX depfile shape only"; + skip("POSIX depfile shape only"); auto plan = minimal_plan(); plan.compileUnits.push_back({ @@ -1079,72 +1074,69 @@ TEST(NinjaBackend, LowercaseAsmHasNoDepfileAndItsOwnRule) { auto body_of = [&](std::string_view rule) { auto start = ninja.find(rule); - EXPECT_NE(start, std::string::npos) << rule << "\n" << ninja; + expect(ne(start, std::string::npos)) << rule << "\n" << ninja; auto end = ninja.find("\n\n", start); return ninja.substr(start, end - start); }; auto upper = body_of("rule asm_object\n"); - EXPECT_NE(upper.find("-MMD -MF $out.d"), std::string::npos) << upper; - EXPECT_NE(upper.find("depfile = $out.d"), std::string::npos) << upper; + expect(ne(upper.find("-MMD -MF $out.d"), std::string::npos)) << upper; + expect(ne(upper.find("depfile = $out.d"), std::string::npos)) << upper; auto lower = body_of("rule asm_object_raw\n"); - EXPECT_EQ(lower.find("-MMD"), std::string::npos) << lower; - EXPECT_EQ(lower.find("depfile"), std::string::npos) << lower; + expect(eq(lower.find("-MMD"), std::string::npos)) << lower; + expect(eq(lower.find("depfile"), std::string::npos)) << lower; // And the edges must be routed to the matching rule. - EXPECT_NE(ninja.find("build obj/upper.o : asm_object src/upper.S"), - std::string::npos) << ninja; - EXPECT_NE(ninja.find("build obj/lower.o : asm_object_raw src/lower.s"), - std::string::npos) << ninja; -} + expect(ne(ninja.find("build obj/upper.o : asm_object src/upper.S"), std::string::npos)) << ninja; + expect(ne(ninja.find("build obj/lower.o : asm_object_raw src/lower.s"), std::string::npos)) << ninja; +}}; // ── #311: staging goes through `mcpp stage`, never an in-place shell copy ── -TEST(NinjaBackend, StageRuleRunsThroughMcppWithRestat) { +UTest stage_rule_runs_through_mcpp_with_restat{"NinjaBackend/StageRuleRunsThroughMcppWithRestat", [] { auto plan = minimal_plan(); auto ninja = emit_ninja_string(plan); - EXPECT_NE(ninja.find("rule stage_file\n"), std::string::npos) << ninja; - EXPECT_NE(ninja.find(" command = $mcpp stage $verify --output $out $in\n"), - std::string::npos) << ninja; + expect(ne(ninja.find("rule stage_file\n"), std::string::npos)) << ninja; + expect(ne(ninja.find(" command = $mcpp stage $verify --output $out $in\n"), std::string::npos)) << ninja; // restat is what keeps a skipped (already-equivalent) stage from dirtying // every importer of the staged BMI. auto rulePos = ninja.find("rule stage_file\n"); auto restatPos = ninja.find(" restat = 1\n", rulePos); auto nextRulePos = ninja.find("\nrule ", rulePos + 1); - ASSERT_NE(restatPos, std::string::npos) << ninja; - EXPECT_LT(restatPos, nextRulePos) << ninja; + expect(fatal(ne(restatPos, std::string::npos))) << ninja; + expect(lt(restatPos, nextRulePos)) << ninja; // The old per-platform in-place copies are gone for good — including the // second one, in runtime_alias, which the Windows job caught still // shelling out to PowerShell. - EXPECT_EQ(ninja.find("Copy-Item"), std::string::npos) << ninja; - EXPECT_EQ(ninja.find("cp -f $in $out"), std::string::npos) << ninja; - EXPECT_EQ(ninja.find("cp_bmi"), std::string::npos) << ninja; -} + expect(eq(ninja.find("Copy-Item"), std::string::npos)) << ninja; + expect(eq(ninja.find("cp -f $in $out"), std::string::npos)) << ninja; + expect(eq(ninja.find("cp_bmi"), std::string::npos)) << ninja; +}}; -TEST(NinjaBackend, RuntimeAliasCopiesThroughStageOnWindowsAndSymlinksElsewhere) { +UTest runtime_alias_copies_through_stage_on_windows_and_symlinks_elsewhere{"NinjaBackend/RuntimeAliasCopiesThroughStageOnWindowsAndSymlinksElsewhere", [] { auto plan = minimal_plan(); auto ninja = emit_ninja_string(plan); auto rulePos = ninja.find("rule runtime_alias\n"); - ASSERT_NE(rulePos, std::string::npos) << ninja; + expect(fatal(ne(rulePos, std::string::npos))) << ninja; auto body = ninja.substr(rulePos, ninja.find("\nrule ", rulePos + 1) - rulePos); if constexpr (mcpplibs::os::is_windows) { // PE has no soname symlink: the alias is a copy of a freshly built DLL, // the same hazard class as BMI staging. - EXPECT_NE(body.find("$mcpp stage --output $out $in"), std::string::npos) << body; - EXPECT_NE(body.find("restat = 1"), std::string::npos) << body; + expect(ne(body.find("$mcpp stage --output $out $in"), std::string::npos)) << body; + expect(ne(body.find("restat = 1"), std::string::npos)) << body; } else { // POSIX must keep the SYMLINK — turning it into a copy would change // what gets packaged, not just how it is written. - EXPECT_NE(body.find("ln -s"), std::string::npos) << body; - EXPECT_EQ(body.find("stage --output"), std::string::npos) << body; + expect(ne(body.find("ln -s"), std::string::npos)) << body; + expect(eq(body.find("stage --output"), std::string::npos)) << body; } -} +}}; -TEST(NinjaBackend, McppBinaryIsBoundEvenWithoutDyndep) { +UTest mcpp_binary_is_bound_even_without_dyndep{"NinjaBackend/McppBinaryIsBoundEvenWithoutDyndep", [] { // dyndep off ⇒ no scan/collect rules, but stage_file still needs $mcpp. std::string ninja; { @@ -1153,22 +1145,22 @@ TEST(NinjaBackend, McppBinaryIsBoundEvenWithoutDyndep) { ninja = emit_ninja_string(plan); } - EXPECT_EQ(ninja.find("rule cxx_scan"), std::string::npos) << ninja; - EXPECT_NE(ninja.find("\nmcpp = "), std::string::npos) << ninja; -} + expect(eq(ninja.find("rule cxx_scan"), std::string::npos)) << ninja; + expect(ne(ninja.find("\nmcpp = "), std::string::npos)) << ninja; +}}; // SPEC-007 R4.2: one destination, one writer. On a PE target two spellings // that differ only in case are one file where the program runs, so they are // one destination; elsewhere they are two. -TEST(DeployDestination, FoldsCaseOnlyOnAPeTarget) { +UTest folds_case_only_on_a_pe_target{"DeployDestination/FoldsCaseOnlyOnAPeTarget", [] { const mcpp::build::BuildPlan::DeployFile d{{"/a/Foo.DLL"}, "bin/Foo.DLL"}; - EXPECT_TRUE(d.is_destination("bin/Foo.DLL", /*peTarget=*/false)); - EXPECT_FALSE(d.is_destination("bin/foo.dll", /*peTarget=*/false)); - EXPECT_TRUE(d.is_destination("bin/foo.dll", /*peTarget=*/true)); - EXPECT_FALSE(d.is_destination("bin/sub/foo.dll", /*peTarget=*/true)); -} + expect(d.is_destination("bin/Foo.DLL", /*peTarget=*/false)); + expect(!d.is_destination("bin/foo.dll", /*peTarget=*/false)); + expect(d.is_destination("bin/foo.dll", /*peTarget=*/true)); + expect(!d.is_destination("bin/sub/foo.dll", /*peTarget=*/true)); +}}; -TEST(NinjaBackend, StdArtifactsAndRuntimeDllsUseTheStageRule) { +UTest std_artifacts_and_runtime_dlls_use_the_stage_rule{"NinjaBackend/StdArtifactsAndRuntimeDllsUseTheStageRule", [] { auto plan = minimal_plan(); plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; plan.toolchain.binaryPath = "/usr/bin/clang++"; @@ -1183,26 +1175,23 @@ TEST(NinjaBackend, StdArtifactsAndRuntimeDllsUseTheStageRule) { // The std artifacts are fingerprint-scoped, so they carry the size-only // check: it needs no OPEN of the destination, which is what lets staging // survive a holder that denies reads (Windows FileShare.None). - EXPECT_NE(ninja.find("build pcm.cache/std.pcm : stage_file " + expect(ne(ninja.find("build pcm.cache/std.pcm : stage_file " "/cache/bmi/fp/pcm.cache/std.pcm\n" - " verify = --verify size\n"), - std::string::npos) << ninja; - EXPECT_NE(ninja.find("build obj/std.o : stage_file /cache/bmi/fp/std.o\n" - " verify = --verify size\n"), - std::string::npos) << ninja; + " verify = --verify size\n"), std::string::npos)) << ninja; + expect(ne(ninja.find("build obj/std.o : stage_file /cache/bmi/fp/std.o\n" + " verify = --verify size\n"), std::string::npos)) << ninja; // std.compat keeps its order-only dependency on the staged std BMI. - EXPECT_NE(ninja.find("build pcm.cache/std.compat.pcm : stage_file " - "/cache/bmi/fp/pcm.cache/std.compat.pcm | pcm.cache/std.pcm"), - std::string::npos) << ninja; + expect(ne(ninja.find("build pcm.cache/std.compat.pcm : stage_file " + "/cache/bmi/fp/pcm.cache/std.compat.pcm | pcm.cache/std.pcm"), std::string::npos)) << ninja; // Windows DLL deployment shares the rule — and therefore the // skip-if-equivalent behaviour that keeps a loaded DLL from failing a build. // But NOT the size-only shortcut: a rebuilt DLL keeps its size (PE sections // are page-padded), so this edge must compare content. auto dllEdge = ninja.find("build bin/libfoo.dll : stage_file /pkg/lib/libfoo.dll\n"); - ASSERT_NE(dllEdge, std::string::npos) << ninja; + expect(fatal(ne(dllEdge, std::string::npos))) << ninja; auto afterDll = ninja.substr(dllEdge, ninja.find('\n', dllEdge + 1) - dllEdge + 1); - EXPECT_EQ(afterDll.find("verify"), std::string::npos) << afterDll; -} + expect(eq(afterDll.find("verify"), std::string::npos)) << afterDll; +}}; // ── SPEC-007 R4.3: a Windows program's runtime DLLs are placed after its link ── @@ -1225,53 +1214,51 @@ BuildPlan program_plan(std::string_view triple, bool withRuntimeDirs) { } // namespace -TEST(NinjaBackend, PeProgramWithRuntimeSearchDirsGetsAPlacementEdge) { +UTest pe_program_with_runtime_search_dirs_gets_a_placement_edge{"NinjaBackend/PeProgramWithRuntimeSearchDirsGetsAPlacementEdge", [] { auto ninja = emit_ninja_string(program_plan("x86_64-w64-windows-gnu", true)); // One rule, carrying the directories in search order, each one word. - EXPECT_NE(ninja.find("rule place_dlls\n"), std::string::npos) << ninja; + expect(ne(ninja.find("rule place_dlls\n"), std::string::npos)) << ninja; // The C++ runtime rule the resolver applied (mcpp.build.runtime_placement): // a GNU PE program has no MSVC runtime set, so the edge is told so and // places by search order. - EXPECT_NE(ninja.find("place-dlls --output $out --depfile $out.d --crt not-applicable $in "), - std::string::npos) << ninja; - EXPECT_NE(ninja.find("my bin"), std::string::npos) << ninja; - EXPECT_NE(ninja.find(" deps = gcc\n"), std::string::npos) << ninja; + expect(ne(ninja.find("place-dlls --output $out --depfile $out.d --crt not-applicable $in "), std::string::npos)) << ninja; + expect(ne(ninja.find("my bin"), std::string::npos)) << ninja; + expect(ne(ninja.find(" deps = gcc\n"), std::string::npos)) << ninja; // One edge per program, after the link it reads, and built by default. - EXPECT_NE(ninja.find("build bin/app.exe.dlls: place_dlls bin/app.exe\n"), - std::string::npos) << ninja; - EXPECT_NE(ninja.find("default bin/app.exe.dlls\n"), std::string::npos) << ninja; -} + expect(ne(ninja.find("build bin/app.exe.dlls: place_dlls bin/app.exe\n"), std::string::npos)) << ninja; + expect(ne(ninja.find("default bin/app.exe.dlls\n"), std::string::npos)) << ninja; +}}; -TEST(NinjaBackend, NoPlacementEdgeWithoutRuntimeSearchDirsOrOffPe) { +UTest no_placement_edge_without_runtime_search_dirs_or_off_pe{"NinjaBackend/NoPlacementEdgeWithoutRuntimeSearchDirsOrOffPe", [] { // A PE program with no runtime search directory has nothing to place. auto pe = emit_ninja_string(program_plan("x86_64-w64-windows-gnu", false)); - EXPECT_EQ(pe.find("place_dlls"), std::string::npos) << pe; + expect(eq(pe.find("place_dlls"), std::string::npos)) << pe; // ELF keeps its run path: the directories are RUNPATH entries, not copies. auto elf = emit_ninja_string(program_plan("x86_64-linux-gnu", true)); - EXPECT_EQ(elf.find("place_dlls"), std::string::npos) << elf; -} + expect(eq(elf.find("place_dlls"), std::string::npos)) << elf; +}}; -TEST(NinjaBackend, DeployedDllsAreOrderOnlyInputsOfTheLink) { +UTest deployed_dlls_are_order_only_inputs_of_the_link{"NinjaBackend/DeployedDllsAreOrderOnlyInputsOfTheLink", [] { // The linker reads the import library, never the deployed DLL, so a DLL // that changes (or a deploy entry a later plan adds) must not relink. auto plan = program_plan("x86_64-w64-windows-gnu", false); plan.runtimeDeployFiles.push_back({{"/pkg/lib/libfoo.dll"}, "bin/libfoo.dll"}); auto ninja = emit_ninja_string(plan); auto link = ninja.find("build bin/app.exe"); - ASSERT_NE(link, std::string::npos) << ninja; + expect(fatal(ne(link, std::string::npos))) << ninja; auto line = ninja.substr(link, ninja.find('\n', link) - link); auto oo = line.find(" || "); - ASSERT_NE(oo, std::string::npos) << line; - EXPECT_NE(line.find("bin/libfoo.dll", oo), std::string::npos) << line; + expect(fatal(ne(oo, std::string::npos))) << line; + expect(ne(line.find("bin/libfoo.dll", oo), std::string::npos)) << line; auto implicitBar = line.find(" | "); - EXPECT_TRUE(implicitBar == std::string::npos || implicitBar >= oo + expect(implicitBar == std::string::npos || implicitBar >= oo || line.substr(implicitBar, oo - implicitBar).find("libfoo.dll") == std::string::npos) << line; -} +}}; // ── #311: staging failures must stay readable through the output filter ── -TEST(NinjaBackend, FilterKeepsStagingDiagnosticsAndFailedTarget) { +UTest filter_keeps_staging_diagnostics_and_failed_target{"NinjaBackend/FilterKeepsStagingDiagnosticsAndFailedTarget", [] { std::vector prefixes = {"/usr/bin/g++", "/opt/mcpp/bin/mcpp"}; std::string raw = "ninja: Entering directory `target/x86_64-linux-gnu/fp'\n" @@ -1287,14 +1274,14 @@ TEST(NinjaBackend, FilterKeepsStagingDiagnosticsAndFailedTarget) { // Which output failed is now preserved (it used to be dropped entirely). // ninja pads the target list with a trailing space; the normalized line // must not inherit it. - EXPECT_NE(filtered.find("failed: pcm.cache/std.pcm\n"), std::string::npos) << filtered; - EXPECT_EQ(filtered.find("[code=1]"), std::string::npos) << filtered; + expect(ne(filtered.find("failed: pcm.cache/std.pcm\n"), std::string::npos)) << filtered; + expect(eq(filtered.find("[code=1]"), std::string::npos)) << filtered; // The diagnostic survives; the echoed command line does not. - EXPECT_NE(filtered.find("error: cannot stage file"), std::string::npos) << filtered; - EXPECT_NE(filtered.find("help:"), std::string::npos) << filtered; - EXPECT_EQ(filtered.find("stage --output"), std::string::npos) << filtered; - EXPECT_EQ(filtered.find("ninja: Entering directory"), std::string::npos) << filtered; -} + expect(ne(filtered.find("error: cannot stage file"), std::string::npos)) << filtered; + expect(ne(filtered.find("help:"), std::string::npos)) << filtered; + expect(eq(filtered.find("stage --output"), std::string::npos)) << filtered; + expect(eq(filtered.find("ninja: Entering directory"), std::string::npos)) << filtered; +}}; // ── Cache-served units: stage edges, not compile edges ────────────────────── // @@ -1305,7 +1292,7 @@ TEST(NinjaBackend, FilterKeepsStagingDiagnosticsAndFailedTarget) { // every "cached" unit was recompiled — the cache saved nothing and the CLI still // printed "Cached". Staging has to be an edge for ninja to have a record. -TEST(NinjaBackend, CachedUnitEmitsStageEdgeAndNoCompileEdge) { +UTest cached_unit_emits_stage_edge_and_no_compile_edge{"NinjaBackend/CachedUnitEmitsStageEdgeAndNoCompileEdge", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "/store/dep/src/dep.cppm", @@ -1320,26 +1307,24 @@ TEST(NinjaBackend, CachedUnitEmitsStageEdgeAndNoCompileEdge) { auto ninja = emit_ninja_string(plan); - EXPECT_NE(ninja.find("build obj/dep.m.o : stage_file " - "/bc/v1/pkg/idx/dep@1.0.0/key/obj/dep.m.o"), - std::string::npos) << ninja; + expect(ne(ninja.find("build obj/dep.m.o : stage_file " + "/bc/v1/pkg/idx/dep@1.0.0/key/obj/dep.m.o"), std::string::npos)) << ninja; // The BMI is staged too, at the path a compile edge would have produced. - EXPECT_NE(ninja.find("stage_file /bc/v1/pkg/idx/dep@1.0.0/key/bmi/dep.gcm"), - std::string::npos) << ninja; + expect(ne(ninja.find("stage_file /bc/v1/pkg/idx/dep@1.0.0/key/bmi/dep.gcm"), std::string::npos)) << ninja; // No compile edge, and no scan edge, for this unit. - EXPECT_EQ(ninja.find("cxx_module /store/dep/src/dep.cppm"), std::string::npos) + expect(eq(ninja.find("cxx_module /store/dep/src/dep.cppm"), std::string::npos)) << ninja; - EXPECT_EQ(ninja.find("cxx_object /store/dep/src/dep.cppm"), std::string::npos) + expect(eq(ninja.find("cxx_object /store/dep/src/dep.cppm"), std::string::npos)) << ninja; - EXPECT_EQ(ninja.find("dep.cppm.ddi"), std::string::npos) << ninja; -} + expect(eq(ninja.find("dep.cppm.ddi"), std::string::npos)) << ninja; +}}; // `--verify size` for the same reason the std artifacts use it: the entry // directory is named by a key covering the toolchain, dialect, profile, the // package's own config and its dependencies' keys, so under one key equal size // IS equivalence — and size comes from directory metadata, which stays readable // even when a holder denies opening the file. -TEST(NinjaBackend, CachedStageEdgesUseSizeVerification) { +UTest cached_stage_edges_use_size_verification{"NinjaBackend/CachedStageEdgesUseSizeVerification", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "/store/dep/src/a.c", @@ -1352,12 +1337,12 @@ TEST(NinjaBackend, CachedStageEdgesUseSizeVerification) { auto ninja = emit_ninja_string(plan); auto at = ninja.find("build obj/a.o : stage_file /cache/obj/a.o"); - ASSERT_NE(at, std::string::npos) << ninja; - EXPECT_NE(ninja.find("verify = --verify size", at), std::string::npos) << ninja; -} + expect(fatal(ne(at, std::string::npos))) << ninja; + expect(ne(ninja.find("verify = --verify size", at), std::string::npos)) << ninja; +}}; // A cached unit sits next to uncached ones; only the cached one changes shape. -TEST(NinjaBackend, UncachedUnitsStillCompileAlongsideCachedOnes) { +UTest uncached_units_still_compile_alongside_cached_ones{"NinjaBackend/UncachedUnitsStillCompileAlongsideCachedOnes", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "/store/dep/src/dep.c", @@ -1375,15 +1360,13 @@ TEST(NinjaBackend, UncachedUnitsStillCompileAlongsideCachedOnes) { }); auto ninja = emit_ninja_string(plan); - EXPECT_NE(ninja.find("build obj/dep.o : stage_file /cache/obj/dep.o"), - std::string::npos) << ninja; - EXPECT_NE(ninja.find("build obj/main.o : cxx_object src/main.cpp"), - std::string::npos) << ninja; -} + expect(ne(ninja.find("build obj/dep.o : stage_file /cache/obj/dep.o"), std::string::npos)) << ninja; + expect(ne(ninja.find("build obj/main.o : cxx_object src/main.cpp"), std::string::npos)) << ninja; +}}; // The default path must be byte-identical to what it was: `servedFromCache` is // false everywhere unless a cache hit set it. -TEST(NinjaBackend, NoStageEdgesWithoutCachedUnits) { +UTest no_stage_edges_without_cached_units{"NinjaBackend/NoStageEdgesWithoutCachedUnits", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", @@ -1393,12 +1376,11 @@ TEST(NinjaBackend, NoStageEdgesWithoutCachedUnits) { }); auto ninja = emit_ninja_string(plan); - EXPECT_EQ(ninja.find("build obj/main.o : stage_file"), std::string::npos) << ninja; - EXPECT_NE(ninja.find("build obj/main.o : cxx_object src/main.cpp"), - std::string::npos) << ninja; -} + expect(eq(ninja.find("build obj/main.o : stage_file"), std::string::npos)) << ninja; + expect(ne(ninja.find("build obj/main.o : cxx_object src/main.cpp"), std::string::npos)) << ninja; +}}; -TEST(NinjaBackend, ObjectOnlyPackageBuildsOwnObjectsAlongsideCacheFills) { +UTest object_only_package_builds_own_objects_alongside_cache_fills{"NinjaBackend/ObjectOnlyPackageBuildsOwnObjectsAlongsideCacheFills", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/provider.c", @@ -1416,16 +1398,16 @@ TEST(NinjaBackend, ObjectOnlyPackageBuildsOwnObjectsAlongsideCacheFills) { auto ninja = emit_ninja_string(plan); auto at = ninja.rfind("\ndefault "); - ASSERT_NE(at, std::string::npos) << ninja; + expect(fatal(ne(at, std::string::npos))) << ninja; auto defaults = ninja.substr(at, ninja.find('\n', at + 1) - at); - EXPECT_NE(defaults.find(" obj/provider.o"), std::string::npos) << defaults; - EXPECT_EQ(count_occurrences(defaults, " obj/dep.o"), 1u) << defaults; -} + expect(ne(defaults.find(" obj/provider.o"), std::string::npos)) << defaults; + expect(eq(count_occurrences(defaults, " obj/dep.o"), 1u)) << defaults; +}}; // A cached unit whose cachedObject was never filled in is a bug upstream, not a // reason to emit a stage edge with an empty input — it must fall back to being // compiled rather than producing an unbuildable graph. -TEST(NinjaBackend, CachedUnitWithoutCachedObjectPathIsStillCompiled) { +UTest cached_unit_without_cached_object_path_is_still_compiled{"NinjaBackend/CachedUnitWithoutCachedObjectPathIsStillCompiled", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", @@ -1436,15 +1418,15 @@ TEST(NinjaBackend, CachedUnitWithoutCachedObjectPathIsStillCompiled) { }); auto ninja = emit_ninja_string(plan); - EXPECT_EQ(ninja.find("stage_file \n"), std::string::npos) << ninja; - EXPECT_EQ(ninja.find("build obj/main.o : stage_file"), std::string::npos) << ninja; -} + expect(eq(ninja.find("stage_file \n"), std::string::npos)) << ninja; + expect(eq(ninja.find("build obj/main.o : stage_file"), std::string::npos)) << ninja; +}}; // A cache-served unit keeps its compile_commands.json entry. The units stay in // the plan (only their EDGE changes shape) precisely so clangd does not lose the // dependency's sources — a cache that silently degraded IDE navigation would be // a bad trade for build time. -TEST(NinjaBackend, CachedUnitsStillAppearInCompileCommands) { +UTest cached_units_still_appear_in_compile_commands{"NinjaBackend/CachedUnitsStillAppearInCompileCommands", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "/store/dep/src/dep.c", @@ -1478,9 +1460,9 @@ TEST(NinjaBackend, CachedUnitsStillAppearInCompileCommands) { sawDep = sawDep || f == dep.string(); sawMain = sawMain || f == main.string(); } - EXPECT_TRUE(sawDep) << cdb; - EXPECT_TRUE(sawMain) << cdb; -} + expect(bool(sawDep)) << cdb; + expect(bool(sawMain)) << cdb; +}}; // Replacing a package's compile edges with stage edges also removes the ordering // those compile edges carried. A module PARTITION is the case that breaks: a @@ -1490,7 +1472,7 @@ TEST(NinjaBackend, CachedUnitsStillAppearInCompileCommands) { // the partition is still unstaged, and Clang fails with `failed to find module // file for module 'pkg:part'`. Observed on macOS CI while Linux won the race, // which is why it is pinned here rather than left to scheduling. -TEST(NinjaBackend, NonCachedEdgesOrderAfterEveryStagedArtifact) { +UTest non_cached_edges_order_after_every_staged_artifact{"NinjaBackend/NonCachedEdgesOrderAfterEveryStagedArtifact", [] { auto plan = minimal_plan(); // A cached package with a primary interface and a partition. plan.compileUnits.push_back({ @@ -1527,29 +1509,29 @@ TEST(NinjaBackend, NonCachedEdgesOrderAfterEveryStagedArtifact) { // One phony aggregating every staged artifact — a per-edge list would repeat // it and grow ninja lines without bound (mcpp#274). auto phony = ninja.find("build _mcpp_staged_cache : phony"); - ASSERT_NE(phony, std::string::npos) << ninja; + expect(fatal(ne(phony, std::string::npos))) << ninja; auto phonyLine = ninja.substr(phony, ninja.find('\n', phony) - phony); for (auto* art : {"obj/dep.m.o", "obj/part.m.o", "gcm.cache/dep.gcm", "gcm.cache/dep-part.gcm"}) { - EXPECT_NE(phonyLine.find(art), std::string::npos) + expect(ne(phonyLine.find(art), std::string::npos)) << art << " missing from: " << phonyLine; } // The consumer's edge orders after it... auto consumer = ninja.find("build obj/main.o"); - ASSERT_NE(consumer, std::string::npos) << ninja; + expect(fatal(ne(consumer, std::string::npos))) << ninja; auto consumerLine = ninja.substr(consumer, ninja.find('\n', consumer) - consumer); - EXPECT_NE(consumerLine.find("|| _mcpp_staged_cache"), std::string::npos) + expect(ne(consumerLine.find("|| _mcpp_staged_cache"), std::string::npos)) << consumerLine; // ...as an ORDER-ONLY dependency, not an implicit one: the real content // dependency is still declared where it always was, so this must add // sequencing without making a staged BMI dirty its consumers. auto bar = consumerLine.find("|| _mcpp_staged_cache"); - EXPECT_EQ(consumerLine.find("| _mcpp_staged_cache"), bar + 1) + expect(eq(consumerLine.find("| _mcpp_staged_cache"), bar + 1)) << "staged phony must appear only after ||, never as an implicit input: " << consumerLine; -} +}}; // The other direction of the same ordering. A staged BMI can import a module // compiled HERE: a unit of the package that the cache entry does not hold, @@ -1561,7 +1543,7 @@ TEST(NinjaBackend, NonCachedEdgesOrderAfterEveryStagedArtifact) { // pass changed the schedule). The stage edge now waits for those BMIs, and it // leaves the aggregate, because every compile edge waits for the aggregate and // the compile it waits for is one of them. -TEST(NinjaBackend, AStagedBmiWaitsForTheModulesItImportsThatCompileHere) { +UTest a_staged_bmi_waits_for_the_modules_it_imports_that_compile_here{"NinjaBackend/AStagedBmiWaitsForTheModulesItImportsThatCompileHere", [] { auto plan = minimal_plan(); // The generated unit of the cached package, compiled here. plan.compileUnits.push_back({ @@ -1622,18 +1604,18 @@ TEST(NinjaBackend, AStagedBmiWaitsForTheModulesItImportsThatCompileHere) { }; auto dep = line_of("build gcm.cache/dep.gcm : stage_file"); - ASSERT_FALSE(dep.empty()) << ninja; - EXPECT_NE(dep.find("|| gcm.cache/dep.gen.gcm"), std::string::npos) << dep; + expect(fatal(!dep.empty())) << ninja; + expect(ne(dep.find("|| gcm.cache/dep.gen.gcm"), std::string::npos)) << dep; auto api = line_of("build gcm.cache/dep.api.gcm : stage_file"); - ASSERT_FALSE(api.empty()) << ninja; - EXPECT_NE(api.find("|| gcm.cache/dep.gcm"), std::string::npos) << api; + expect(fatal(!api.empty())) << ninja; + expect(ne(api.find("|| gcm.cache/dep.gcm"), std::string::npos)) << api; auto util = line_of("build gcm.cache/dep.util.gcm : stage_file"); - ASSERT_FALSE(util.empty()) << ninja; - EXPECT_EQ(util.find("||"), std::string::npos) << util; + expect(fatal(!util.empty())) << ninja; + expect(eq(util.find("||"), std::string::npos)) << util; // The aggregate holds the BMIs that wait for nothing, and every object. auto phony = line_of("build _mcpp_staged_cache : phony"); - ASSERT_FALSE(phony.empty()) << ninja; + expect(fatal(!phony.empty())) << ninja; auto words = [](const std::string& s) { std::set w; std::istringstream is(s); @@ -1641,19 +1623,19 @@ TEST(NinjaBackend, AStagedBmiWaitsForTheModulesItImportsThatCompileHere) { return w; }; auto held = words(phony); - EXPECT_FALSE(held.contains("gcm.cache/dep.gcm")) << phony; - EXPECT_FALSE(held.contains("gcm.cache/dep.api.gcm")) << phony; - EXPECT_TRUE(held.contains("gcm.cache/dep.util.gcm")) << phony; + expect(!held.contains("gcm.cache/dep.gcm")) << phony; + expect(!held.contains("gcm.cache/dep.api.gcm")) << phony; + expect(held.contains("gcm.cache/dep.util.gcm")) << phony; for (auto* o : {"obj/dep.m.o", "obj/api.m.o", "obj/util.m.o"}) - EXPECT_TRUE(held.contains(o)) << o << " missing from: " << phony; + expect(held.contains(o)) << o << " missing from: " << phony; // The generated unit still compiles after the aggregate: no cycle. auto gen = line_of("build obj/gen.m.o"); - ASSERT_FALSE(gen.empty()) << ninja; - EXPECT_NE(gen.find("|| _mcpp_staged_cache"), std::string::npos) << gen; -} + expect(fatal(!gen.empty())) << ninja; + expect(ne(gen.find("|| _mcpp_staged_cache"), std::string::npos)) << gen; +}}; -TEST(NinjaBackend, NoStagedPhonyWhenNothingIsCached) { +UTest no_staged_phony_when_nothing_is_cached{"NinjaBackend/NoStagedPhonyWhenNothingIsCached", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", @@ -1662,8 +1644,8 @@ TEST(NinjaBackend, NoStagedPhonyWhenNothingIsCached) { .packageName = "objc_rule_test", }); auto ninja = emit_ninja_string(plan); - EXPECT_EQ(ninja.find("_mcpp_staged_cache"), std::string::npos) << ninja; -} + expect(eq(ninja.find("_mcpp_staged_cache"), std::string::npos)) << ninja; +}}; // #390: the main-manifest include channel's fallback join — `root / inc` for // a directory a build step will create LATER (the `generated/inc` shape) — @@ -1673,25 +1655,25 @@ TEST(NinjaBackend, NoStagedPhonyWhenNothingIsCached) { // rule for an absolute entry written with forward slashes (`C:/SDL2/include`). // (The existing-directory path runs through expand_dir_glob, covered by // Scanner.ExpandDirGlobMultiSegmentUsesNativeSeparators.) -TEST(Plan, ExpandManifestIncludeEntryNativeSpelling) { +UTest expand_manifest_include_entry_native_spelling{"Plan/ExpandManifestIncludeEntryNativeSpelling", [] { namespace fs = std::filesystem; auto root = fs::temp_directory_path() / "mcpp-plan-inc-entry"; fs::remove_all(root); fs::create_directories(root); // generated/inc deliberately does NOT exist auto fb = expand_manifest_include_entry(root, fs::path("generated/inc")); - ASSERT_EQ(fb.size(), 1u); + expect(fatal(eq(fb.size(), 1u))); if constexpr (fs::path::preferred_separator == '\\') - EXPECT_EQ(fb[0].string().find('/'), std::string::npos) << fb[0]; - EXPECT_EQ(fb[0], root / "generated" / "inc"); + expect(eq(fb[0].string().find('/'), std::string::npos)) << fb[0]; + expect(eq(fb[0], root / "generated" / "inc")); auto abs = expand_manifest_include_entry(root, fs::path("C:/SDL2/include")); - ASSERT_EQ(abs.size(), 1u); + expect(fatal(eq(abs.size(), 1u))); if constexpr (fs::path::preferred_separator == '\\') - EXPECT_EQ(abs[0].string(), "C:\\SDL2\\include"); + expect(eq(abs[0].string(), "C:\\SDL2\\include")); fs::remove_all(root); -} +}}; // ── PE `toolchain-coupled`: the toolset's CRT travels with the artifact ───── // @@ -1755,20 +1737,20 @@ std::vector toolset_entries(const mcpp::build::CompileFla } // namespace -TEST(NinjaBackendPeRuntime, ToolchainCoupledStagesTheToolsetCrtBesideTheExe) { +UTest toolchain_coupled_stages_the_toolset_crt_beside_the_exe{"NinjaBackendPeRuntime/ToolchainCoupledStagesTheToolsetCrtBesideTheExe", [] { FakeRedistDir redist; auto plan = msvc_plan_with_redist(redist, "toolchain-coupled"); auto flags = compute_flags(plan); auto toolset = toolset_entries(flags); - ASSERT_EQ(toolset.size(), 3u) + expect(fatal(eq(toolset.size(), 3u))) << "expected the three .dll and not the .manifest beside them"; for (auto const& d : toolset) { - EXPECT_EQ(d.dest.parent_path(), std::filesystem::path("bin")) + expect(eq(d.dest.parent_path(), std::filesystem::path("bin"))) << "a DLL must land in the same directory as the .exe: " << d.dest.string(); - ASSERT_EQ(d.sources.size(), 1u); - EXPECT_EQ(d.sources.front().extension(), ".dll") << d.sources.front().string(); + expect(fatal(eq(d.sources.size(), 1u))); + expect(eq(d.sources.front().extension(), ".dll")) << d.sources.front().string(); } std::string placements; @@ -1776,39 +1758,39 @@ TEST(NinjaBackendPeRuntime, ToolchainCoupledStagesTheToolsetCrtBesideTheExe) { // #734 E4: two or more placements are one `stage_list` edge whose outputs // are every destination, and the list the edge reads names each pair. const auto listAt = ninja.find(": stage_list "); - ASSERT_NE(listAt, std::string::npos) << ninja; + expect(fatal(ne(listAt, std::string::npos))) << ninja; const auto listLine = ninja.substr(ninja.rfind("\nbuild ", listAt) + 1, ninja.find('\n', listAt) - ninja.rfind("\nbuild ", listAt) - 1); for (auto name : {"vcruntime140.dll", "msvcp140.dll", "vcruntime140_1.dll"}) { - EXPECT_NE(listLine.find(std::format("bin/{}", name)), std::string::npos) + expect(ne(listLine.find(std::format("bin/{}", name)), std::string::npos)) << name << " is not an output of the placement edge\n" << ninja; - EXPECT_NE(placements.find(std::format("\tbin/{}", name)), std::string::npos) + expect(ne(placements.find(std::format("\tbin/{}", name)), std::string::npos)) << name << " is not in the placement list\n" << placements; } - EXPECT_EQ(ninja.find("Microsoft.VC143.CRT.manifest"), std::string::npos) + expect(eq(ninja.find("Microsoft.VC143.CRT.manifest"), std::string::npos)) << "copied something that is not a runtime DLL\n" << ninja; // Reachability, twice over. A copy edge nothing asks for is never run // under explicit ninja goals, and an .exe that does not depend on its DLLs // can be reported "up to date" while they are missing. auto exeAt = ninja.find("build bin/app.exe :"); - ASSERT_NE(exeAt, std::string::npos) << ninja; + expect(fatal(ne(exeAt, std::string::npos))) << ninja; auto exeStanza = ninja.substr(exeAt, ninja.find("\nbuild ", exeAt + 1) - exeAt); - EXPECT_NE(exeStanza.find("bin/vcruntime140.dll"), std::string::npos) + expect(ne(exeStanza.find("bin/vcruntime140.dll"), std::string::npos)) << "the executable does not depend on the CRT staged beside it\n" << exeStanza; auto defaultAt = ninja.rfind("\ndefault "); - ASSERT_NE(defaultAt, std::string::npos) << ninja; - EXPECT_NE(ninja.find("bin/vcruntime140.dll", defaultAt), std::string::npos) + expect(fatal(ne(defaultAt, std::string::npos))) << ninja; + expect(ne(ninja.find("bin/vcruntime140.dll", defaultAt), std::string::npos)) << "the staged CRT is not a default goal\n" << ninja.substr(defaultAt); -} +}}; -TEST(NinjaBackendPeRuntime, HostCoupledStagesNothing) { +UTest host_coupled_stages_nothing{"NinjaBackendPeRuntime/HostCoupledStagesNothing", [] { // An EXPLICIT `host-coupled`: the machine is a promise that it provides // the DLLs, and keeping a copy beside the artifact would contradict it. FakeRedistDir redist; auto plan = msvc_plan_with_redist(redist, "host-coupled"); - EXPECT_TRUE(compute_flags(plan).runtimeDeploy.empty()); + expect(compute_flags(plan).runtimeDeploy.empty()); // The BARE default (#718): `toolchain-coupled` is now the MSVC-ABI // default for every role, so a project that never mentioned @@ -1816,11 +1798,11 @@ TEST(NinjaBackendPeRuntime, HostCoupledStagesNothing) { // explicit `toolchain-coupled` would — see // `ToolchainCoupledStagesTheToolsetCrtBesideTheExe`. auto bare = msvc_plan_with_redist(redist, ""); - EXPECT_EQ(toolset_entries(compute_flags(bare)).size(), 3u) + expect(eq(toolset_entries(compute_flags(bare)).size(), 3u)) << "the undeclared MSVC-ABI default no longer stages the redistributable"; -} +}}; -TEST(NinjaBackendPeRuntime, AProjectsOwnDeployFileOutranksTheToolsets) { +UTest a_projects_own_deploy_file_outranks_the_toolsets{"NinjaBackendPeRuntime/AProjectsOwnDeployFileOutranksTheToolsets", [] { // A vendored redist named in `[runtime] deploy_files` is a human's // statement about which build of msvcp140.dll this program ships. Silently // replacing it with the toolset's copy produces a different program than @@ -1834,24 +1816,24 @@ TEST(NinjaBackendPeRuntime, AProjectsOwnDeployFileOutranksTheToolsets) { auto flags = compute_flags(plan); for (auto const& d : toolset_entries(flags)) - EXPECT_NE(d.dest.filename(), std::filesystem::path("msvcp140.dll")) + expect(ne(d.dest.filename(), std::filesystem::path("msvcp140.dll"))) << "overwrote the project's own deploy file"; - EXPECT_EQ(toolset_entries(flags).size(), 2u); - EXPECT_TRUE(std::ranges::any_of(flags.runtimeDeploy, [](auto const& d) { + expect(eq(toolset_entries(flags).size(), 2u)); + expect(std::ranges::any_of(flags.runtimeDeploy, [](auto const& d) { return d.origin == BuildPlan::DeployFile::Origin::Declared && d.sources.front() == std::filesystem::path("/vendor/msvcp140.dll"); })) << "the declared file is not placed"; - EXPECT_TRUE(std::ranges::any_of(flags.runtimeNotes, [](auto const& d) { + expect(std::ranges::any_of(flags.runtimeNotes, [](auto const& d) { return d.find("msvcp140.dll") != std::string::npos; })) << "the declared runtime file was placed without a word about its version"; -} +}}; // The 2026-09-28 design, §2.9: a build for a Windows target has one C++ // runtime, the toolset's, for the program and for the tools that build it. // Every action runs with the toolset's runtime directory first on its PATH, // through the named wrapper, whatever its role. A target without the MSVC ABI // keeps its commands as they were. -TEST(NinjaBackendPeRuntime, EveryActionOfAWindowsTargetHasTheToolsetRuntimeOnPath) { +UTest every_action_of_a_windows_target_has_the_toolset_runtime_on_path{"NinjaBackendPeRuntime/EveryActionOfAWindowsTargetHasTheToolsetRuntimeOnPath", [] { FakeRedistDir redist; auto plan = msvc_plan_with_redist(redist, ""); mcpp::manifest::BuildAction a; @@ -1864,21 +1846,21 @@ TEST(NinjaBackendPeRuntime, EveryActionOfAWindowsTargetHasTheToolsetRuntimeOnPat auto ninja = emit_ninja_string(plan); auto ruleAt = ninja.find("rule mcpp_action_0"); - ASSERT_NE(ruleAt, std::string::npos) << ninja; + expect(fatal(ne(ruleAt, std::string::npos))) << ninja; auto rule = ninja.substr(ruleAt, ninja.find("\n\n", ruleAt) - ruleAt); - EXPECT_NE(rule.find(" __action"), std::string::npos) << rule; - EXPECT_NE(rule.find("--path-prepend"), std::string::npos) << rule; - EXPECT_NE(rule.find(redist.path.filename().string()), std::string::npos) + expect(ne(rule.find(" __action"), std::string::npos)) << rule; + expect(ne(rule.find("--path-prepend"), std::string::npos)) << rule; + expect(ne(rule.find(redist.path.filename().string()), std::string::npos)) << "the prepended directory is not the toolset's runtime directory\n" << rule; auto elf = minimal_plan(); elf.actions.push_back(a); auto elfNinja = emit_ninja_string(elf); - EXPECT_EQ(elfNinja.find("--path-prepend"), std::string::npos) + expect(eq(elfNinja.find("--path-prepend"), std::string::npos)) << "a target without the MSVC ABI received the Windows runtime on PATH\n" << elfNinja; -} +}}; -TEST(NinjaBackendPeRuntime, AnElfToolchainNeverStagesItsRuntimeDirs) { +UTest an_elf_toolchain_never_stages_its_runtime_dirs{"NinjaBackendPeRuntime/AnElfToolchainNeverStagesItsRuntimeDirs", [] { // `linkRuntimeDirs` means "the toolchain's private runtime" for every // provider — on gcc it is libstdc++'s directory. That is reached through // an rpath, and copying its contents into an output tree would be a @@ -1888,8 +1870,8 @@ TEST(NinjaBackendPeRuntime, AnElfToolchainNeverStagesItsRuntimeDirs) { plan.toolchain.compiler = mcpp::toolchain::CompilerId::GCC; plan.toolchain.binaryPath = "/usr/bin/g++"; plan.toolchain.targetTriple = "x86_64-linux-gnu"; - EXPECT_TRUE(toolset_entries(compute_flags(plan)).empty()); -} + expect(toolset_entries(compute_flags(plan)).empty()); +}}; // #718: every MSVC-ABI row x {undeclared, self-contained, toolchain-coupled, // host-coupled, linkage=static} yields exactly one CRT word, spelled for its @@ -1900,7 +1882,7 @@ TEST(NinjaBackendPeRuntime, AnElfToolchainNeverStagesItsRuntimeDirs) { // link line is `link.exe` directly (`LinkShape::MsvcLinkExe`) and carries no // compile flag at all — the CRT is baked into the objects it is given — so // only the LLVM row's link line is asked to repeat the word. -TEST(NinjaBackendPeRuntime, CrtWordIsOneWordEqualOnCompileAndLink) { +UTest crt_word_is_one_word_equal_on_compile_and_link{"NinjaBackendPeRuntime/CrtWordIsOneWordEqualOnCompileAndLink", [] { FakeRedistDir redist; struct Row { std::string_view label; @@ -1928,13 +1910,13 @@ TEST(NinjaBackendPeRuntime, CrtWordIsOneWordEqualOnCompileAndLink) { auto flags = compute_flags(plan); auto want = c.wantsStatic ? row.staticWord : row.dynamicWord; auto other = c.wantsStatic ? row.dynamicWord : row.staticWord; - SCOPED_TRACE(std::format("{} linkage='{}' cxx_runtime='{}'", - row.label, c.linkage, c.cxxRuntime)); + /* trace: std::format("{} linkage='{}' cxx_runtime='{}'", + row.label, c.linkage, c.cxxRuntime) */; - EXPECT_EQ(count_occurrences(flags.cxx, want), 1u) << flags.cxx; - EXPECT_EQ(count_occurrences(flags.cc, want), 1u) << flags.cc; - EXPECT_EQ(flags.cxx.find(other), std::string::npos); - EXPECT_EQ(flags.cc.find(other), std::string::npos); + expect(eq(count_occurrences(flags.cxx, want), 1u)) << flags.cxx; + expect(eq(count_occurrences(flags.cc, want), 1u)) << flags.cc; + expect(eq(flags.cxx.find(other), std::string::npos)); + expect(eq(flags.cc.find(other), std::string::npos)); // `link_shape` (flags.cppm) picks `LinkShape::PeLld` — the // branch that carries this word on the link line — only when @@ -1946,14 +1928,14 @@ TEST(NinjaBackendPeRuntime, CrtWordIsOneWordEqualOnCompileAndLink) { // see the Windows e2e leg (814) for that half. if constexpr (mcpplibs::os::is_windows) { if (row.compiler == mcpp::toolchain::CompilerId::Clang) { - EXPECT_EQ(count_occurrences(flags.ld, want), 1u) << flags.ld; - EXPECT_EQ(flags.ld.find(other), std::string::npos); - EXPECT_EQ(flags.ld, flags.ldC); + expect(eq(count_occurrences(flags.ld, want), 1u)) << flags.ld; + expect(eq(flags.ld.find(other), std::string::npos)); + expect(eq(flags.ld, flags.ldC)); } } } } -} +}}; // ── link_failure_advice ────────────────────────────────────────────────────── // @@ -1961,45 +1943,45 @@ TEST(NinjaBackendPeRuntime, CrtWordIsOneWordEqualOnCompileAndLink) { // symbol from a header inside the standard library. Nothing in that message // says which package supplies it, or that the answer is one manifest line. -TEST(LinkFailureAdvice, NamesTheAllocatorFeatureForLldsSpelling) { +UTest names_the_allocator_feature_for_llds_spelling{"LinkFailureAdvice/NamesTheAllocatorFeatureForLldsSpelling", [] { const std::string out = "ld.lld: error: undefined symbol: operator new(unsigned long)\n" ">>> referenced by allocate.h:58\n"; auto advice = mcpp::build::link_failure_advice(out); - EXPECT_NE(advice.find("alloc-kal"), std::string::npos); - EXPECT_NE(advice.find("std-freestanding"), std::string::npos); + expect(ne(advice.find("alloc-kal"), std::string::npos)); + expect(ne(advice.find("std-freestanding"), std::string::npos)); // The four that are most often forgotten are named, because omitting them // produces a SECOND link failure after the first is fixed. - EXPECT_NE(advice.find("align_val_t"), std::string::npos); -} + expect(ne(advice.find("align_val_t"), std::string::npos)); +}}; -TEST(LinkFailureAdvice, MatchesGnuLdsSpellingToo) { +UTest matches_gnu_lds_spelling_too{"LinkFailureAdvice/MatchesGnuLdsSpellingToo", [] { // Two linkers, two spellings of the same fact. Matching only lld's would // make the advice appear on some toolchains and not others. const std::string out = "/usr/bin/ld: main.o: undefined reference to `operator new(unsigned long)'\n"; - EXPECT_FALSE(mcpp::build::link_failure_advice(out).empty()); -} + expect(!(mcpp::build::link_failure_advice(out).empty())); +}}; -TEST(LinkFailureAdvice, StaysSilentOnUnrelatedFailures) { +UTest stays_silent_on_unrelated_failures{"LinkFailureAdvice/StaysSilentOnUnrelatedFailures", [] { // The advice is appended to EVERY failed build, so a needle that matched // loosely would attach allocator advice to a missing board symbol. const std::string out = "ld.lld: error: undefined symbol: board_uart_init\n" "error: cannot open crt0.o: No such file or directory\n"; - EXPECT_TRUE(mcpp::build::link_failure_advice(out).empty()); -} + expect(mcpp::build::link_failure_advice(out).empty()); +}}; -TEST(LinkFailureAdvice, CarriesNoVersionLiteral) { +UTest carries_no_version_literal{"LinkFailureAdvice/CarriesNoVersionLiteral", [] { // The `import std` advice needed tests/e2e/135 to keep its version // honest. This one is built so it cannot go stale the same way: it names a // package and a FEATURE, and the feature pulls the implementation. A digit // sequence that looks like a version here would be a regression in kind. auto advice = mcpp::build::link_failure_advice( "ld.lld: error: undefined symbol: operator new(unsigned long)\n"); - ASSERT_FALSE(advice.empty()); - EXPECT_EQ(advice.find("0."), std::string::npos) << advice; -} + expect(fatal(!advice.empty())); + expect(eq(advice.find("0."), std::string::npos)) << advice; +}}; // ── graph_c_library_isolation_advice (mcpp#662) ───────────────────────────── // @@ -2010,14 +1992,14 @@ TEST(LinkFailureAdvice, CarriesNoVersionLiteral) { // use to diagnose it: the isolating token on the command line, together with // the compiler's own "file not found". -TEST(GraphCLibraryIsolationAdvice, FiresOnlyWhenBothTheTokenAndTheErrorAreThere) { +UTest fires_only_when_both_the_token_and_the_error_are_there{"GraphCLibraryIsolationAdvice/FiresOnlyWhenBothTheTokenAndTheErrorAreThere", [] { // Neither alone is enough. - EXPECT_TRUE(mcpp::build::graph_c_library_isolation_advice( + expect(mcpp::build::graph_c_library_isolation_advice( "clang: error: something else entirely\n").empty()); - EXPECT_TRUE(mcpp::build::graph_c_library_isolation_advice( + expect(mcpp::build::graph_c_library_isolation_advice( "-nostdlibinc -std=c11 -c probe.c\n" "probe.c:1:1: error: unknown type name 'nonsense'\n").empty()); - EXPECT_TRUE(mcpp::build::graph_c_library_isolation_advice( + expect(mcpp::build::graph_c_library_isolation_advice( "clang -xc -std=c11 -c probe.c\n" "probe.c:1:10: fatal error: 'io.h' file not found\n").empty()); // Both together: an ordinary compile failure the fix's own token is on. @@ -2027,13 +2009,13 @@ TEST(GraphCLibraryIsolationAdvice, FiresOnlyWhenBothTheTokenAndTheErrorAreThere) "-nostdinc++ -c probe.c -o obj/probe.c.o\n" "probe.c:1:10: fatal error: 'io.h' file not found\n" " 1 | #include \n"; - EXPECT_FALSE(mcpp::build::graph_c_library_isolation_advice(out).empty()); -} + expect(!(mcpp::build::graph_c_library_isolation_advice(out).empty())); +}}; // WHETHER THE C LIBRARY IS THE GRAPH'S reaches the fast path through a file // beside build.ninja (LLVM 23.1.3 Part 3, D3): a native build on a managed C // library carries `-nostdlibinc` too, so the token alone no longer answers it. -TEST(GraphCLibrarySidecar, PresentOnlyForAGraphCLibrary) { +UTest present_only_for_a_graph_c_library{"GraphCLibrarySidecar/PresentOnlyForAGraphCLibrary", [] { const auto dir = std::filesystem::temp_directory_path() / std::format("mcpp_graph_clib_{}", std::random_device{}()); std::filesystem::create_directories(dir); @@ -2044,37 +2026,37 @@ TEST(GraphCLibrarySidecar, PresentOnlyForAGraphCLibrary) { graph.impl = "openkal-musl@0.3.5"; mcpp::build::write_graph_c_library_sidecar(dir, graph); auto read = mcpp::build::read_graph_c_library_sidecar(dir); - ASSERT_TRUE(read.has_value()); - EXPECT_EQ(read->first, "musl"); - EXPECT_EQ(read->second, "openkal-musl@0.3.5"); + expect(fatal(read.has_value())); + expect(eq(read->first, "musl")); + expect(eq(read->second, "openkal-musl@0.3.5")); // A payload C library (the native managed glibc) removes the file. mcpp::targetside::Layer payload; payload.origin = mcpp::targetside::Origin::Payload; payload.interfaceName = "glibc"; mcpp::build::write_graph_c_library_sidecar(dir, payload); - EXPECT_FALSE(mcpp::build::read_graph_c_library_sidecar(dir).has_value()); + expect(!(mcpp::build::read_graph_c_library_sidecar(dir).has_value())); std::error_code ec; std::filesystem::remove_all(dir, ec); -} +}}; -TEST(GraphCLibraryIsolationAdvice, NamesTheLibraryWhenGiven) { +UTest names_the_library_when_given{"GraphCLibraryIsolationAdvice/NamesTheLibraryWhenGiven", [] { const std::string out = "clang -nostdlibinc -c probe.c\n" "probe.c:1:10: fatal error: 'io.h' file not found\n"; auto advice = mcpp::build::graph_c_library_isolation_advice( out, "musl", "openkal-musl@0.3.5"); - ASSERT_FALSE(advice.empty()); - EXPECT_NE(advice.find("musl"), std::string::npos); - EXPECT_NE(advice.find("openkal-musl@0.3.5"), std::string::npos); + expect(fatal(!advice.empty())); + expect(ne(advice.find("musl"), std::string::npos)); + expect(ne(advice.find("openkal-musl@0.3.5"), std::string::npos)); // Both remedies from the plan: adapting to the C library, and bringing // the platform dependency into the graph privately. - EXPECT_NE(advice.find("cfg(c-abi = \"musl\")"), std::string::npos); - EXPECT_NE(advice.find("private"), std::string::npos); -} + expect(ne(advice.find("cfg(c-abi = \"musl\")"), std::string::npos)); + expect(ne(advice.find("private"), std::string::npos)); +}}; -TEST(GraphCLibraryIsolationAdvice, DegradesWithoutNamingAnyPackage) { +UTest degrades_without_naming_any_package{"GraphCLibraryIsolationAdvice/DegradesWithoutNamingAnyPackage", [] { // The fast path (execute.cppm) has no BuildPlan to read a name from. The // note still fires — it does not stay silent just because the caller // could not name the library — and it does not fabricate a name either. @@ -2082,9 +2064,9 @@ TEST(GraphCLibraryIsolationAdvice, DegradesWithoutNamingAnyPackage) { "clang -nostdlibinc -c probe.c\n" "probe.c:1:10: fatal error: 'io.h' file not found\n"; auto advice = mcpp::build::graph_c_library_isolation_advice(out); - EXPECT_FALSE(advice.empty()); - EXPECT_NE(advice.find("this target's C library"), std::string::npos); -} + expect(!advice.empty()); + expect(ne(advice.find("this target's C library"), std::string::npos)); +}}; // ── consumer_include_scope_advice (#690, design record F7) ────────────────── // @@ -2105,7 +2087,7 @@ std::filesystem::path consumer_include_fixture() { } } // namespace -TEST(ConsumerIncludeScopeAdvice, NamesTheConsumerDirectoryForEachDriverSpelling) { +UTest names_the_consumer_directory_for_each_driver_spelling{"ConsumerIncludeScopeAdvice/NamesTheConsumerDirectoryForEachDriverSpelling", [] { const auto root = consumer_include_fixture(); const std::vector dirs{root / "absent", root / "appinc"}; const std::string gcc = @@ -2118,40 +2100,40 @@ TEST(ConsumerIncludeScopeAdvice, NamesTheConsumerDirectoryForEachDriverSpelling) "No such file or directory\n"; for (auto const& out : {gcc, clang, cl}) { auto advice = mcpp::build::consumer_include_scope_advice(out, dirs); - ASSERT_FALSE(advice.empty()) << out; - EXPECT_NE(advice.find((root / "appinc").string()), std::string::npos) << advice; - EXPECT_NE(advice.find("no longer"), std::string::npos) << advice; + expect(fatal(!advice.empty())) << out; + expect(ne(advice.find((root / "appinc").string()), std::string::npos)) << advice; + expect(ne(advice.find("no longer"), std::string::npos)) << advice; } std::filesystem::remove_all(root); -} +}}; -TEST(ConsumerIncludeScopeAdvice, SilentWhenTheHeaderIsNotTheConsumersOrNoHeaderIsMissing) { +UTest silent_when_the_header_is_not_the_consumers_or_no_header_is_missing{"ConsumerIncludeScopeAdvice/SilentWhenTheHeaderIsNotTheConsumersOrNoHeaderIsMissing", [] { const auto root = consumer_include_fixture(); const std::vector dirs{root / "appinc"}; // A missing header the consumer does not have either: an ordinary error. - EXPECT_TRUE(mcpp::build::consumer_include_scope_advice( + expect(mcpp::build::consumer_include_scope_advice( "dep.c:1:10: fatal error: other.h: No such file or directory\n", dirs).empty()); // A failure that is not a missing header, although cfg7.h is the consumer's. - EXPECT_TRUE(mcpp::build::consumer_include_scope_advice( + expect(mcpp::build::consumer_include_scope_advice( "dep.c:3:1: error: unknown type name 'cfg7'\n", dirs).empty()); // No root include directories at all (the fast path before any plan wrote // the sidecar). - EXPECT_TRUE(mcpp::build::consumer_include_scope_advice( + expect(mcpp::build::consumer_include_scope_advice( "dep.c:1:10: fatal error: cfg7.h: No such file or directory\n", {}).empty()); std::filesystem::remove_all(root); -} +}}; -TEST(ConsumerIncludeScopeAdvice, SidecarRoundTrips) { +UTest sidecar_round_trips{"ConsumerIncludeScopeAdvice/SidecarRoundTrips", [] { const auto root = consumer_include_fixture(); const std::vector dirs{root / "appinc", root / "with space"}; mcpp::build::write_consumer_include_sidecar(root, dirs); - EXPECT_EQ(mcpp::build::read_consumer_include_sidecar(root), dirs); + expect(eq(mcpp::build::read_consumer_include_sidecar(root), dirs)); mcpp::build::write_consumer_include_sidecar(root, {}); - EXPECT_TRUE(mcpp::build::read_consumer_include_sidecar(root).empty()); + expect(mcpp::build::read_consumer_include_sidecar(root).empty()); std::filesystem::remove_all(root); -} +}}; -TEST(GraphCLibraryIsolationAdvice, DoesNotRewriteTheCompilersOwnLine) { +UTest does_not_rewrite_the_compilers_own_line{"GraphCLibraryIsolationAdvice/DoesNotRewriteTheCompilersOwnLine", [] { // "Reuse the channel, don't rewrite compiler output": the advice is // APPENDED text, and the original diagnostic line survives byte for byte // wherever a caller concatenates it (as both call sites do). @@ -2159,10 +2141,10 @@ TEST(GraphCLibraryIsolationAdvice, DoesNotRewriteTheCompilersOwnLine) { "probe.c:1:10: fatal error: 'io.h' file not found\n"; const std::string out = "clang -nostdlibinc -c probe.c\n" + compilerLine; auto advice = mcpp::build::graph_c_library_isolation_advice(out); - ASSERT_FALSE(advice.empty()); + expect(fatal(!advice.empty())); std::string combined = out + advice; - EXPECT_NE(combined.find(compilerLine), std::string::npos); -} + expect(ne(combined.find(compilerLine), std::string::npos)); +}}; // ═══ mcpp#533 / mcpp#534: emitter self-checks ══════════════════════════════ @@ -2179,7 +2161,7 @@ TEST(GraphCLibraryIsolationAdvice, DoesNotRewriteTheCompilersOwnLine) { // `emit_ninja_string` is supposed never to produce. A test that could only // reach it through a BuildPlan could not show the checker works at all. -TEST(NinjaEmitterGuards, ARuleWhoseCommandStartsWithAnUndefinedVariableIsRefused) { +UTest a_rule_whose_command_starts_with_an_undefined_variable_is_refused{"NinjaEmitterGuards/ARuleWhoseCommandStartsWithAnUndefinedVariableIsRefused", [] { const std::string manifest = "cxx = /usr/bin/g++\n" "\n" @@ -2187,19 +2169,19 @@ TEST(NinjaEmitterGuards, ARuleWhoseCommandStartsWithAnUndefinedVariableIsRefused " command = $cc -shared $in -o $out\n" " description = SHARED $out\n"; auto bad = check_rule_commands_name_a_program(manifest); - ASSERT_TRUE(bad.has_value()) << manifest; - EXPECT_NE(bad->find("c_shared"), std::string::npos) << *bad; - EXPECT_NE(bad->find("$cc"), std::string::npos) << *bad; -} + expect(fatal(bad.has_value())) << manifest; + if (auto&& failed = expect(ne(bad->find("c_shared"), std::string::npos)); !failed) failed << *bad; + if (auto&& failed = expect(ne(bad->find("$cc"), std::string::npos)); !failed) failed << *bad; +}}; -TEST(NinjaEmitterGuards, ARuleWhoseLeadingVariableIsDefinedButEmptyIsRefused) { +UTest a_rule_whose_leading_variable_is_defined_but_empty_is_refused{"NinjaEmitterGuards/ARuleWhoseLeadingVariableIsDefinedButEmptyIsRefused", [] { const std::string manifest = "cc =\n" "\n" "rule c_link\n" " command = $cc $in -o $out\n"; - EXPECT_TRUE(check_rule_commands_name_a_program(manifest).has_value()); -} + expect(check_rule_commands_name_a_program(manifest).has_value()); +}}; // The negatives, which are what stop this from being an always-fires check. // Note especially `$soname_flag`: variables set per-EDGE are legitimately @@ -2208,7 +2190,7 @@ TEST(NinjaEmitterGuards, ARuleWhoseLeadingVariableIsDefinedButEmptyIsRefused) { // not about undefined variables in general — the general form would have // needed an allowlist, and a hand-maintained exception list is the shape this // file has been burned by twice. -TEST(NinjaEmitterGuards, WellFormedRulesAndOptionalPerEdgeVariablesPass) { +UTest well_formed_rules_and_optional_per_edge_variables_pass{"NinjaEmitterGuards/WellFormedRulesAndOptionalPerEdgeVariablesPass", [] { const std::string manifest = "cc = /usr/bin/gcc\n" "cxx = /usr/bin/g++\n" @@ -2223,22 +2205,22 @@ TEST(NinjaEmitterGuards, WellFormedRulesAndOptionalPerEdgeVariablesPass) { " tool = /usr/bin/objcopy\n" " command = $tool -O binary $in $out\n"; auto bad = check_rule_commands_name_a_program(manifest); - EXPECT_FALSE(bad.has_value()) << (bad ? *bad : std::string{}); -} + if (auto&& failed = expect(!bad.has_value()); !failed) failed << (bad ? *bad : std::string{}); +}}; // The emitter's own output must satisfy its own guard — including the case // that produced the defect: a plan whose compile set has no C or asm unit. -TEST(NinjaEmitterGuards, EmittedManifestsAlwaysNameAProgram) { +UTest emitted_manifests_always_name_a_program{"NinjaEmitterGuards/EmittedManifestsAlwaysNameAProgram", [] { auto plan = minimal_plan(); auto ninja = emit_ninja_string(plan); auto bad = check_rule_commands_name_a_program(ninja); - EXPECT_FALSE(bad.has_value()) << (bad ? *bad : std::string{}); + if (auto&& failed = expect(!bad.has_value()); !failed) failed << (bad ? *bad : std::string{}); // `cc` unconditionally, not "when something C is being compiled". - EXPECT_NE(ninja.find("\ncc = "), std::string::npos) + expect(ne(ninja.find("\ncc = "), std::string::npos)) << "cc is not defined in a plan with no C sources — this is mcpp#533\n" << ninja; -} +}}; // ── A package's compiles wait for its generators (mcpp#534) ──────────────── @@ -2268,34 +2250,34 @@ BuildPlan plan_with_generated_header() { } // namespace -TEST(ActionOrdering, AGeneratedHeaderGetsAPhonyAndTheCompileEdgeWaitsForIt) { +UTest a_generated_header_gets_a_phony_and_the_compile_edge_waits_for_it{"ActionOrdering/AGeneratedHeaderGetsAPhonyAndTheCompileEdgeWaitsForIt", [] { auto ninja = emit_ninja_string(plan_with_generated_header()); - EXPECT_NE(ninja.find("build mcpp-actions-gen_pkg : phony"), std::string::npos) + expect(ne(ninja.find("build mcpp-actions-gen_pkg : phony"), std::string::npos)) << ninja; - EXPECT_NE(ninja.find("|| mcpp-actions-gen_pkg"), std::string::npos) + expect(ne(ninja.find("|| mcpp-actions-gen_pkg"), std::string::npos)) << "the compile edge does not wait for the generator\n" << ninja; -} +}}; // THE DENOMINATOR. "every edge that should carry the ordering does" is // vacuously true when no edge does — and that vacuum is exactly the state this // change fixes, so the count of edges is asserted separately from the count // that carries it. A bare equality would have passed against the defect. -TEST(ActionOrdering, EveryCompileEdgeOfThatPackageCarriesIt) { +UTest every_compile_edge_of_that_package_carries_it{"ActionOrdering/EveryCompileEdgeOfThatPackageCarriesIt", [] { auto ninja = emit_ninja_string(plan_with_generated_header()); const auto edges = count_occurrences(ninja, "build obj/uses_header.o :"); const auto ordered = count_occurrences(ninja, "|| mcpp-actions-gen_pkg"); - EXPECT_GT(edges, 0u) << "no compile edge was emitted at all — the " + expect(gt(edges, 0u)) << "no compile edge was emitted at all — the " "assertion below would describe nothing\n" << ninja; - EXPECT_GE(ordered, edges) << ninja; -} + expect(ge(ordered, edges)) << ninja; +}}; // A package with no gating action gets no phony, and its edges are untouched. // Without this the change would read as "always add an order-only edge", which // is not what it does and would serialise builds that have nothing to wait for. -TEST(ActionOrdering, APackageWithNoActionsIsUnchanged) { +UTest a_package_with_no_actions_is_unchanged{"ActionOrdering/APackageWithNoActionsIsUnchanged", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/plain.cpp", @@ -2304,14 +2286,14 @@ TEST(ActionOrdering, APackageWithNoActionsIsUnchanged) { .packageName = "plain_pkg", }); auto ninja = emit_ninja_string(plan); - EXPECT_EQ(ninja.find("mcpp-actions-plain_pkg"), std::string::npos) << ninja; -} + expect(eq(ninja.find("mcpp-actions-plain_pkg"), std::string::npos)) << ninja; +}}; // `Object` and `Artifact` really are ordered by file dependency — their // outputs are link inputs and their inputs are link outputs — so pulling them // into the phony would add an edge that expresses nothing. This is the half of // the original comment that was true, and it has to stay true. -TEST(ActionOrdering, ObjectRoleActionsDoNotJoinTheCompileGate) { +UTest object_role_actions_do_not_join_the_compile_gate{"ActionOrdering/ObjectRoleActionsDoNotJoinTheCompileGate", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/x.cpp", @@ -2328,14 +2310,14 @@ TEST(ActionOrdering, ObjectRoleActionsDoNotJoinTheCompileGate) { plan.actions.push_back(std::move(a)); auto ninja = emit_ninja_string(plan); - EXPECT_EQ(ninja.find("mcpp-actions-obj_pkg"), std::string::npos) << ninja; -} + expect(eq(ninja.find("mcpp-actions-obj_pkg"), std::string::npos)) << ninja; +}}; // A non-blocking check runs alongside compilation; a blocking one precedes it. // `blocking` was typed, transported, parsed, documented in two languages and // demonstrated in a shipped example — and read by nothing, which made it a // no-op with a paper trail. -TEST(ActionOrdering, BlockingDecidesWhetherACheckGatesTheCompile) { +UTest blocking_decides_whether_a_check_gates_the_compile{"ActionOrdering/BlockingDecidesWhetherACheckGatesTheCompile", [] { auto make = [](bool blocking) { auto plan = minimal_plan(); plan.compileUnits.push_back({ @@ -2355,11 +2337,11 @@ TEST(ActionOrdering, BlockingDecidesWhetherACheckGatesTheCompile) { return emit_ninja_string(plan); }; - EXPECT_NE(make(true).find("|| mcpp-actions-chk_pkg"), std::string::npos) + expect(ne(make(true).find("|| mcpp-actions-chk_pkg"), std::string::npos)) << "blocking = true did not gate the compile"; - EXPECT_EQ(make(false).find("mcpp-actions-chk_pkg"), std::string::npos) + expect(eq(make(false).find("mcpp-actions-chk_pkg"), std::string::npos)) << "blocking = false gated the compile anyway"; -} +}}; // ── depfile: ninja must track what the action's COMMAND read, not only what // the build program declared as an input ────────────────────────────────── @@ -2389,7 +2371,7 @@ std::string action_rule_block(std::string_view ninja, std::string_view rule_head } // namespace -TEST(ActionDepfile, ADeclaredDepfileEmitsDepfileAndDepsGcc) { +UTest a_declared_depfile_emits_depfile_and_deps_gcc{"ActionDepfile/ADeclaredDepfileEmitsDepfileAndDepsGcc", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/shader_user.cpp", @@ -2408,27 +2390,27 @@ TEST(ActionDepfile, ADeclaredDepfileEmitsDepfileAndDepsGcc) { auto ninja = emit_ninja_string(plan); auto block = action_rule_block(ninja, "rule mcpp_action_0\n"); - ASSERT_FALSE(block.empty()) << "no rule emitted for the action\n" << ninja; - EXPECT_NE(block.find("\n depfile = out/shader.spv.d\n"), std::string::npos) + expect(fatal(!block.empty())) << "no rule emitted for the action\n" << ninja; + expect(ne(block.find("\n depfile = out/shader.spv.d\n"), std::string::npos)) << block; // No trailing "\n" required: `deps = gcc` is the last line this backend // emits for the rule before the blank separator, so the block extracted // above (up to, not including, that separator) ends exactly here. - EXPECT_NE(block.find("\n deps = gcc"), std::string::npos) << block; + expect(ne(block.find("\n deps = gcc"), std::string::npos)) << block; // The depfile must never also be a declared ninja OUTPUT of the same // edge: `deps = gcc` makes ninja consume and delete it once read, and a // file simultaneously promised as an output would be one ninja expects // to still exist afterward. - EXPECT_EQ(ninja.find("shader.spv.d :"), std::string::npos) + expect(eq(ninja.find("shader.spv.d :"), std::string::npos)) << "the depfile must not appear as a build edge's own output\n" << ninja; -} +}}; // THE CONTROL. Without it, a backend that emitted `depfile =`/`deps = gcc` // unconditionally for every action would also pass the test above — exactly // the shape `blocking` took before anything read it (mcpp#534 / e2e 315): // typed, transported, parsed, and a no-op with a paper trail. -TEST(ActionDepfile, NoDepfileEmitsNeitherLine) { +UTest no_depfile_emits_neither_line{"ActionDepfile/NoDepfileEmitsNeitherLine", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/plain_user.cpp", @@ -2446,10 +2428,10 @@ TEST(ActionDepfile, NoDepfileEmitsNeitherLine) { auto ninja = emit_ninja_string(plan); auto block = action_rule_block(ninja, "rule mcpp_action_0\n"); - ASSERT_FALSE(block.empty()) << "no rule emitted for the action\n" << ninja; - EXPECT_EQ(block.find("depfile ="), std::string::npos) << block; - EXPECT_EQ(block.find("deps = gcc"), std::string::npos) << block; -} + expect(fatal(!block.empty())) << "no rule emitted for the action\n" << ninja; + expect(eq(block.find("depfile ="), std::string::npos)) << block; + expect(eq(block.find("deps = gcc"), std::string::npos)) << block; +}}; // A module-extension file need not provide a module: an implementation unit // (`module M;`) is a legal inhabitant of a `.cppm` and provides nothing @@ -2464,7 +2446,7 @@ TEST(ActionDepfile, NoDepfileEmitsNeitherLine) { // in module declaration`. GCC's interface spelling is the // plain language, so the identical project built on GCC // and not on Clang. -TEST(NinjaBackend, ModuleEdgeFlagsComeFromTheScanNotTheExtension) { +UTest module_edge_flags_come_from_the_scan_not_the_extension{"NinjaBackend/ModuleEdgeFlagsComeFromTheScanNotTheExtension", [] { auto plan = minimal_plan(); plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; plan.toolchain.binaryPath = "/usr/bin/clang++"; @@ -2488,12 +2470,12 @@ TEST(NinjaBackend, ModuleEdgeFlagsComeFromTheScanNotTheExtension) { // being undone by someone restoring a rule-level constant that "works" // for every edge that happens to provide a module. auto rule_start = ninja.find("rule cxx_module\n"); - ASSERT_NE(rule_start, std::string::npos) << ninja; + expect(fatal(ne(rule_start, std::string::npos))) << ninja; auto rule = ninja.substr(rule_start, ninja.find("\n\n", rule_start) - rule_start); - EXPECT_NE(rule.find("$module_output $module_lang"), std::string::npos) << rule; - EXPECT_EQ(rule.find("-fmodule-output=$bmi_out"), std::string::npos) + expect(ne(rule.find("$module_output $module_lang"), std::string::npos)) << rule; + expect(eq(rule.find("-fmodule-output=$bmi_out"), std::string::npos)) << "the BMI flag is per-edge; a rule-level one reaches edges with no BMI"; - EXPECT_EQ(rule.find("-x c++-module"), std::string::npos) + expect(eq(rule.find("-x c++-module"), std::string::npos)) << "the interface spelling is per-edge, not a property of the rule"; // EVERY cxx_module EDGE BINDS module_lang, AND THE DENOMINATOR IS ASSERTED. @@ -2507,26 +2489,25 @@ TEST(NinjaBackend, ModuleEdgeFlagsComeFromTheScanNotTheExtension) { ++with_lang; pos = end; } - ASSERT_EQ(edges, 2u) << ninja; - EXPECT_EQ(with_lang, edges) << ninja; + expect(fatal(eq(edges, 2u))) << ninja; + expect(eq(with_lang, edges)) << ninja; // The interface: told it is an interface, and given somewhere to put the BMI. - EXPECT_NE(ninja.find(" module_lang = -x c++-module\n"), std::string::npos) << ninja; - EXPECT_NE(ninja.find(" module_output = -fmodule-output=pcm.cache/iface.pcm\n"), - std::string::npos) << ninja; + expect(ne(ninja.find(" module_lang = -x c++-module\n"), std::string::npos)) << ninja; + expect(ne(ninja.find(" module_output = -fmodule-output=pcm.cache/iface.pcm\n"), std::string::npos)) << ninja; // The implementation unit: told it is C++, and given no BMI path at all. - EXPECT_NE(ninja.find(" module_lang = -x c++\n"), std::string::npos) << ninja; - EXPECT_EQ(count_occurrences(ninja, " module_output ="), 1u) + expect(ne(ninja.find(" module_lang = -x c++\n"), std::string::npos)) << ninja; + expect(eq(count_occurrences(ninja, " module_output ="), 1u)) << "only the unit that provides a module names a BMI"; // The empty-value spelling must not appear anywhere in the graph. - EXPECT_EQ(ninja.find("-fmodule-output= "), std::string::npos) << ninja; - EXPECT_EQ(ninja.find("-fmodule-output=\n"), std::string::npos) << ninja; -} + expect(eq(ninja.find("-fmodule-output= "), std::string::npos)) << ninja; + expect(eq(ninja.find("-fmodule-output=\n"), std::string::npos)) << ninja; +}}; // GCC needs no explicit module output at all, so the same two units must emit // `module_lang` twice and `module_output` never. Without this the test above // would leave "the GCC branch still emits a flag it does not want" unmeasured. -TEST(NinjaBackend, GccModuleEdgesCarryTheLanguageAndNoBmiFlag) { +UTest gcc_module_edges_carry_the_language_and_no_bmi_flag{"NinjaBackend/GccModuleEdgesCarryTheLanguageAndNoBmiFlag", [] { auto plan = minimal_plan(); // GCC plan.compileUnits.push_back({ .source = "src/iface.cppm", @@ -2543,15 +2524,15 @@ TEST(NinjaBackend, GccModuleEdgesCarryTheLanguageAndNoBmiFlag) { }); auto ninja = emit_ninja_string(plan); - EXPECT_EQ(count_occurrences(ninja, " module_lang = -x c++\n"), 2u) << ninja; - EXPECT_EQ(count_occurrences(ninja, " module_output ="), 0u) << ninja; -} + expect(eq(count_occurrences(ninja, " module_lang = -x c++\n"), 2u)) << ninja; + expect(eq(count_occurrences(ninja, " module_output ="), 0u)) << ninja; +}}; // The third dialect, and the one where the empty value was worst. `/ifcOutput` // takes its path as a SEPARATE token, so `/ifcOutput ` followed by nothing // consumed whatever came next on the command line -- the observed failure was // `could not open output file '/interface'`, naming a flag as a filename. -TEST(NinjaBackend, MsvcModuleEdgesSplitTheInterfaceFlagFromTheLanguageFlag) { +UTest msvc_module_edges_split_the_interface_flag_from_the_language_flag{"NinjaBackend/MsvcModuleEdgesSplitTheInterfaceFlagFromTheLanguageFlag", [] { auto plan = minimal_plan(); plan.toolchain.compiler = mcpp::toolchain::CompilerId::MSVC; plan.toolchain.binaryPath = "cl.exe"; @@ -2573,14 +2554,14 @@ TEST(NinjaBackend, MsvcModuleEdgesSplitTheInterfaceFlagFromTheLanguageFlag) { // `/interface /TP` says both "this is an interface" and "this is C++"; a // unit that is not an interface keeps only the second half. - EXPECT_NE(ninja.find(" module_lang = /interface /TP\n"), std::string::npos) << ninja; - EXPECT_NE(ninja.find(" module_lang = /TP\n"), std::string::npos) << ninja; - EXPECT_EQ(count_occurrences(ninja, " module_lang = /interface /TP\n"), 1u) << ninja; + expect(ne(ninja.find(" module_lang = /interface /TP\n"), std::string::npos)) << ninja; + expect(ne(ninja.find(" module_lang = /TP\n"), std::string::npos)) << ninja; + expect(eq(count_occurrences(ninja, " module_lang = /interface /TP\n"), 1u)) << ninja; // Exactly one edge names an .ifc, and it is the one that provides. - EXPECT_EQ(count_occurrences(ninja, " module_output = /ifcOutput "), 1u) << ninja; + expect(eq(count_occurrences(ninja, " module_output = /ifcOutput "), 1u)) << ninja; // The trailing-space spelling with nothing after it must not exist. - EXPECT_EQ(ninja.find("/ifcOutput \n"), std::string::npos) << ninja; -} + expect(eq(ninja.find("/ifcOutput \n"), std::string::npos)) << ninja; +}}; // ── #618: `windows_subsystem` / `windows_entry` ───────────────────────────── // @@ -2599,26 +2580,26 @@ using Flags = std::vector; } // namespace -TEST(NinjaBackend, WindowsKeysRenderNothingOffPe) { +UTest windows_keys_render_nothing_off_pe{"NinjaBackend/WindowsKeysRenderNothingOffPe", [] { for (auto triple : {"x86_64-linux-gnu", "x86_64-linux-musl", "aarch64-macos"}) { auto plan = plan_for_triple(triple); for (bool sep : {false, true}) - EXPECT_EQ(windows_executable_link_flags(plan, sep, "windows", "wWinMain"), Flags{}) + expect(eq(windows_executable_link_flags(plan, sep, "windows", "wWinMain"), Flags{})) << triple; } -} +}}; -TEST(NinjaBackend, WindowsDefaultsRenderNothingOnEitherAbi) { +UTest windows_defaults_render_nothing_on_either_abi{"NinjaBackend/WindowsDefaultsRenderNothingOnEitherAbi", [] { for (auto triple : {"x86_64-windows-msvc", "x86_64-windows-gnu"}) { auto plan = plan_for_triple(triple); for (auto [subsystem, entry] : {std::pair{"", ""}, std::pair{"console", ""}, std::pair{"", "main"}, std::pair{"console", "main"}}) - EXPECT_EQ(windows_executable_link_flags(plan, false, subsystem, entry), Flags{}) + expect(eq(windows_executable_link_flags(plan, false, subsystem, entry), Flags{})) << triple << " " << subsystem << " " << entry; } -} +}}; -TEST(NinjaBackend, WindowsMsvcAbiStatesBothSubsystemAndCrtEntry) { +UTest windows_msvc_abi_states_both_subsystem_and_crt_entry{"NinjaBackend/WindowsMsvcAbiStatesBothSubsystemAndCrtEntry", [] { auto plan = plan_for_triple("x86_64-windows-msvc"); const std::tuple rows[] = { {"windows", "", {"/SUBSYSTEM:WINDOWS", "/ENTRY:mainCRTStartup"}}, @@ -2631,17 +2612,17 @@ TEST(NinjaBackend, WindowsMsvcAbiStatesBothSubsystemAndCrtEntry) { }; for (auto const& [subsystem, entry, expected] : rows) { // link.exe invoked directly takes the flag bare... - EXPECT_EQ(windows_executable_link_flags(plan, true, subsystem, entry), expected) + expect(eq(windows_executable_link_flags(plan, true, subsystem, entry), expected)) << subsystem << " " << entry; // ...and a GNU-style driver (clang targeting the MSVC ABI) passes it on. Flags wrapped; for (auto const& f : expected) wrapped.push_back("-Wl," + f); - EXPECT_EQ(windows_executable_link_flags(plan, false, subsystem, entry), wrapped) + expect(eq(windows_executable_link_flags(plan, false, subsystem, entry), wrapped)) << subsystem << " " << entry; } -} +}}; -TEST(NinjaBackend, WindowsGnuAbiUsesTheDriverFlags) { +UTest windows_gnu_abi_uses_the_driver_flags{"NinjaBackend/WindowsGnuAbiUsesTheDriverFlags", [] { auto plan = plan_for_triple("x86_64-windows-gnu"); const std::tuple rows[] = { {"windows", "", {"-mwindows"}}, @@ -2652,13 +2633,13 @@ TEST(NinjaBackend, WindowsGnuAbiUsesTheDriverFlags) { {"", "WinMain", {}}, }; for (auto const& [subsystem, entry, expected] : rows) - EXPECT_EQ(windows_executable_link_flags(plan, false, subsystem, entry), expected) + expect(eq(windows_executable_link_flags(plan, false, subsystem, entry), expected)) << subsystem << " " << entry; -} +}}; // Through the emitted graph: the flags reach the declaring executable's link // edge and no other edge, including a test binary of the same package. -TEST(NinjaBackend, WindowsSubsystemReachesOnlyTheDeclaringExecutable) { +UTest windows_subsystem_reaches_only_the_declaring_executable{"NinjaBackend/WindowsSubsystemReachesOnlyTheDeclaringExecutable", [] { auto plan = plan_for_triple("x86_64-windows-gnu"); plan.compileUnits.push_back({ .source = "src/gui.cpp", @@ -2700,14 +2681,14 @@ TEST(NinjaBackend, WindowsSubsystemReachesOnlyTheDeclaringExecutable) { plan.linkUnits.push_back(test); auto ninja = emit_ninja_string(plan); - EXPECT_EQ(count_occurrences(ninja, "-mwindows"), 1u) << ninja; + expect(eq(count_occurrences(ninja, "-mwindows"), 1u)) << ninja; const auto guiEdge = ninja.find("build bin/gui.exe"); - ASSERT_NE(guiEdge, std::string::npos) << ninja; + expect(fatal(ne(guiEdge, std::string::npos))) << ninja; const auto nextEdge = ninja.find("\nbuild ", guiEdge + 1); const auto flag = ninja.find("-mwindows"); - EXPECT_GT(flag, guiEdge) << ninja; - EXPECT_LT(flag, nextEdge) << ninja; -} + expect(gt(flag, guiEdge)) << ninja; + expect(lt(flag, nextEdge)) << ninja; +}}; // #694 (W1d): THE LEVEL A PROFILE DECLARES IS THE LEVEL EVERY ROW REALISES. // @@ -2718,7 +2699,7 @@ TEST(NinjaBackend, WindowsSubsystemReachesOnlyTheDeclaringExecutable) { // compilers, so a later branch that overrides a declared level fails here and // not in a published binary. `realised_opt_level` is the answer both the flags // and the `Finished` line read, so it is asserted against the same rows. -TEST(NinjaBackendOptimization, EveryTargetRowRealisesTheDeclaredLevel) { +UTest every_target_row_realises_the_declared_level{"NinjaBackendOptimization/EveryTargetRowRealisesTheDeclaredLevel", [] { using mcpp::toolchain::CompilerId; for (auto const& row : mcpp::toolchain::triple::kKnownTargets) { for (auto compiler : {CompilerId::GCC, CompilerId::Clang}) { @@ -2735,21 +2716,21 @@ TEST(NinjaBackendOptimization, EveryTargetRowRealisesTheDeclaredLevel) { const std::string where = std::format( "target={} compiler={} opt={}", row.canonical, static_cast(compiler), level); - EXPECT_NE(f.cxx.find(want), std::string::npos) << where << "\n" << f.cxx; - EXPECT_NE(f.cc.find(want), std::string::npos) << where << "\n" << f.cc; - EXPECT_EQ(f.cxx.find(" -Og"), std::string::npos) << where << "\n" << f.cxx; - EXPECT_EQ(f.cc.find(" -Og"), std::string::npos) << where << "\n" << f.cc; - EXPECT_EQ(realised_opt_level(plan.manifest.buildConfig), level) << where; - EXPECT_EQ(realises_optimization(plan.manifest.buildConfig), level != "0") + expect(ne(f.cxx.find(want), std::string::npos)) << where << "\n" << f.cxx; + expect(ne(f.cc.find(want), std::string::npos)) << where << "\n" << f.cc; + expect(eq(f.cxx.find(" -Og"), std::string::npos)) << where << "\n" << f.cxx; + expect(eq(f.cc.find(" -Og"), std::string::npos)) << where << "\n" << f.cc; + expect(eq(realised_opt_level(plan.manifest.buildConfig), level)) << where; + expect(eq(realises_optimization(plan.manifest.buildConfig), level != "0")) << where; } } } -} +}}; // The MSVC dialect spells the same levels its own way: `/Od` for zero (there is // no `/O0`) and `/O` otherwise. -TEST(NinjaBackendOptimization, MsvcDialectRealisesTheDeclaredLevel) { +UTest msvc_dialect_realises_the_declared_level{"NinjaBackendOptimization/MsvcDialectRealisesTheDeclaredLevel", [] { for (std::string level : {"0", "1", "2"}) { auto plan = minimal_plan(); plan.toolchain.compiler = mcpp::toolchain::CompilerId::MSVC; @@ -2758,23 +2739,23 @@ TEST(NinjaBackendOptimization, MsvcDialectRealisesTheDeclaredLevel) { plan.manifest.buildConfig.optLevel = level; auto f = compute_flags(plan); const std::string want = level == "0" ? " /Od" : " /O" + level; - EXPECT_NE(f.cxx.find(want), std::string::npos) << level << "\n" << f.cxx; - EXPECT_NE(f.cc.find(want), std::string::npos) << level << "\n" << f.cc; + expect(ne(f.cxx.find(want), std::string::npos)) << level << "\n" << f.cxx; + expect(ne(f.cc.find(want), std::string::npos)) << level << "\n" << f.cc; } -} +}}; // An empty level is zero, for the flags and for the `Finished` line alike. The // old spelling rendered it as a bare `-O`, which GCC reads as `-O1`, while the // line called the build unoptimized. -TEST(NinjaBackendOptimization, AnEmptyLevelIsZeroForFlagsAndDescriptor) { +UTest an_empty_level_is_zero_for_flags_and_descriptor{"NinjaBackendOptimization/AnEmptyLevelIsZeroForFlagsAndDescriptor", [] { auto plan = minimal_plan(); plan.manifest.buildConfig.optLevel.clear(); auto f = compute_flags(plan); - EXPECT_NE(f.cxx.find(" -O0"), std::string::npos) << f.cxx; - EXPECT_EQ(f.cxx.find(" -O "), std::string::npos) << f.cxx; - EXPECT_EQ(realised_opt_level(plan.manifest.buildConfig), "0"); - EXPECT_FALSE(realises_optimization(plan.manifest.buildConfig)); -} + expect(ne(f.cxx.find(" -O0"), std::string::npos)) << f.cxx; + expect(eq(f.cxx.find(" -O "), std::string::npos)) << f.cxx; + expect(eq(realised_opt_level(plan.manifest.buildConfig), "0")); + expect(!realises_optimization(plan.manifest.buildConfig)); +}}; // #695 and #690 (C2): THE FILE-LEVEL FLAGS CARRY NOTHING A PACKAGE DECLARES FOR // ITSELF. @@ -2784,7 +2765,7 @@ TEST(NinjaBackendOptimization, AnEmptyLevelIsZeroForFlagsAndDescriptor) { // that channel until #691, and the root's C standard until #695. The root here // declares every package-private key; none of those values may appear in the // file-level flags. The C standard there is the engine's constant. -TEST(NinjaBackendScope, TheFileLevelFlagsCarryNoPackagePrivateValue) { +UTest the_file_level_flags_carry_no_package_private_value{"NinjaBackendScope/TheFileLevelFlagsCarryNoPackagePrivateValue", [] { auto plan = minimal_plan(); auto& bc = plan.manifest.buildConfig; bc.cStandard = "gnu11"; @@ -2796,12 +2777,12 @@ TEST(NinjaBackendScope, TheFileLevelFlagsCarryNoPackagePrivateValue) { auto f = compute_flags(plan); for (auto const* line : {&f.cc, &f.cxx}) { - EXPECT_EQ(line->find("gnu11"), std::string::npos) << *line; - EXPECT_EQ(line->find("/root-private/include"), std::string::npos) << *line; - EXPECT_EQ(line->find("ROOT_ONLY"), std::string::npos) << *line; + if (auto&& failed = expect(eq(line->find("gnu11"), std::string::npos)); !failed) failed << *line; + if (auto&& failed = expect(eq(line->find("/root-private/include"), std::string::npos)); !failed) failed << *line; + if (auto&& failed = expect(eq(line->find("ROOT_ONLY"), std::string::npos)); !failed) failed << *line; } - EXPECT_NE(f.cc.find("-std=c11"), std::string::npos) << f.cc; -} + expect(ne(f.cc.find("-std=c11"), std::string::npos)) << f.cc; +}}; // #696 (W2b): AN ELF LINK OVER A GRAPH-SUPPLIED C LIBRARY SEARCHES NOTHING OF // THE HOST'S. With no sysroot, clang derived its library search from `/` and, @@ -2825,42 +2806,42 @@ BuildPlan graph_c_library_plan(std::string_view triple, } } // namespace -TEST(NinjaBackendGraphLink, AnElfGraphLinkByClangCarriesTheEmptySysroot) { +UTest an_elf_graph_link_by_clang_carries_the_empty_sysroot{"NinjaBackendGraphLink/AnElfGraphLinkByClangCarriesTheEmptySysroot", [] { for (auto triple : {"x86_64-linux-musl", "aarch64-linux-musl"}) { auto plan = graph_c_library_plan(triple, mcpp::toolchain::CompilerId::Clang); auto f = compute_flags(plan); - EXPECT_TRUE(f.graphLinkIsolated) << triple; - EXPECT_NE(f.ld.find("--sysroot="), std::string::npos) << triple << "\n" << f.ld; - EXPECT_NE(f.ld.find(std::string(kGraphLinkSysrootDir)), std::string::npos) + expect(bool(f.graphLinkIsolated)) << triple; + expect(ne(f.ld.find("--sysroot="), std::string::npos)) << triple << "\n" << f.ld; + expect(ne(f.ld.find(std::string(kGraphLinkSysrootDir)), std::string::npos)) << triple << "\n" << f.ld; } -} +}}; // The measured case is clang on ELF; GCC over the graph and PE links keep their // line until they are measured, and a payload C library keeps its own sysroot. -TEST(NinjaBackendGraphLink, OtherLinksDoNotCarryTheEmptySysroot) { +UTest other_links_do_not_carry_the_empty_sysroot{"NinjaBackendGraphLink/OtherLinksDoNotCarryTheEmptySysroot", [] { { auto plan = graph_c_library_plan("x86_64-linux-musl", mcpp::toolchain::CompilerId::GCC); auto f = compute_flags(plan); - EXPECT_FALSE(f.graphLinkIsolated); - EXPECT_EQ(f.ld.find(std::string(kGraphLinkSysrootDir)), std::string::npos) << f.ld; + expect(!f.graphLinkIsolated); + expect(eq(f.ld.find(std::string(kGraphLinkSysrootDir)), std::string::npos)) << f.ld; } { auto plan = graph_c_library_plan("x86_64-windows-musl", mcpp::toolchain::CompilerId::Clang); auto f = compute_flags(plan); - EXPECT_FALSE(f.graphLinkIsolated); - EXPECT_EQ(f.ld.find(std::string(kGraphLinkSysrootDir)), std::string::npos) << f.ld; + expect(!f.graphLinkIsolated); + expect(eq(f.ld.find(std::string(kGraphLinkSysrootDir)), std::string::npos)) << f.ld; } { auto plan = minimal_plan(); auto f = compute_flags(plan); - EXPECT_FALSE(f.graphLinkIsolated); - EXPECT_EQ(f.ld.find(std::string(kGraphLinkSysrootDir)), std::string::npos) << f.ld; + expect(!f.graphLinkIsolated); + expect(eq(f.ld.find(std::string(kGraphLinkSysrootDir)), std::string::npos)) << f.ld; } -} +}}; // #696: the note for an unanswered `-l` on an isolated graph link. -TEST(GraphLinkLibraryAdvice, NamesTheLibraryAndTheMuslRelease) { +UTest names_the_library_and_the_musl_release{"GraphLinkLibraryAdvice/NamesTheLibraryAndTheMuslRelease", [] { const std::string out = "FAILED: bin/app\n" "clang++ @bin/app.rsp -o bin/app --sysroot=/p/target/x/graph-sysroot -lm -lpthread\n" @@ -2868,29 +2849,29 @@ TEST(GraphLinkLibraryAdvice, NamesTheLibraryAndTheMuslRelease) { "ld.lld: error: unable to find library -lpthread\n" "ld.lld: error: unable to find library -lm\n"; auto advice = graph_link_library_advice(out, "musl", "openkal-musl@0.19.1"); - EXPECT_NE(advice.find("musl (openkal-musl@0.19.1)"), std::string::npos) << advice; - EXPECT_NE(advice.find("-lm, -lpthread"), std::string::npos) << advice; - EXPECT_NE(advice.find("openkal-musl 0.19.2"), std::string::npos) << advice; - EXPECT_NE(advice.find("mcpp#696"), std::string::npos) << advice; -} + expect(ne(advice.find("musl (openkal-musl@0.19.1)"), std::string::npos)) << advice; + expect(ne(advice.find("-lm, -lpthread"), std::string::npos)) << advice; + expect(ne(advice.find("openkal-musl 0.19.2"), std::string::npos)) << advice; + expect(ne(advice.find("mcpp#696"), std::string::npos)) << advice; +}}; -TEST(GraphLinkLibraryAdvice, AnotherLibraryGetsNoMuslParagraph) { +UTest another_library_gets_no_musl_paragraph{"GraphLinkLibraryAdvice/AnotherLibraryGetsNoMuslParagraph", [] { const std::string out = "clang++ -o bin/app --sysroot=/p/target/x/graph-sysroot -lz\n" "ld.lld: error: unable to find library -lz\n"; auto advice = graph_link_library_advice(out, "musl", "openkal-musl@0.19.2"); - EXPECT_NE(advice.find("-lz"), std::string::npos) << advice; - EXPECT_EQ(advice.find("openkal-musl 0.19.2 and later"), std::string::npos) << advice; -} + expect(ne(advice.find("-lz"), std::string::npos)) << advice; + expect(eq(advice.find("openkal-musl 0.19.2 and later"), std::string::npos)) << advice; +}}; -TEST(GraphLinkLibraryAdvice, StaysSilentWithoutTheGraphSysroot) { +UTest stays_silent_without_the_graph_sysroot{"GraphLinkLibraryAdvice/StaysSilentWithoutTheGraphSysroot", [] { const std::string out = "clang++ -o bin/app -lm\n" "ld.lld: error: unable to find library -lm\n"; - EXPECT_TRUE(graph_link_library_advice(out, "musl", "openkal-musl@0.19.1").empty()); - EXPECT_TRUE(graph_link_library_advice( + expect(graph_link_library_advice(out, "musl", "openkal-musl@0.19.1").empty()); + expect(graph_link_library_advice( "clang++ --sysroot=/p/graph-sysroot\nld.lld: error: undefined symbol: foo\n").empty()); -} +}}; // ─── #693 (W4d): the encodings the tools read ─────────────────────────────── // @@ -2915,7 +2896,7 @@ std::string rsp_line(const std::string& ninja, std::string_view rule) { } // namespace -TEST(NinjaBackendEncoding, MsvcResponseFilesBeginWithTheByteOrderMark) { +UTest msvc_response_files_begin_with_the_byte_order_mark{"NinjaBackendEncoding/MsvcResponseFilesBeginWithTheByteOrderMark", [] { auto plan = minimal_plan(); plan.toolchain.compiler = mcpp::toolchain::CompilerId::MSVC; plan.toolchain.binaryPath = "cl.exe"; @@ -2938,12 +2919,12 @@ TEST(NinjaBackendEncoding, MsvcResponseFilesBeginWithTheByteOrderMark) { "rule cxx_link\n", "rule cxx_archive\n", "rule cxx_shared\n"}) { auto line = rsp_line(ninja, rule); - ASSERT_FALSE(line.empty()) << rule << ninja; - EXPECT_NE(line.find("= \xEF\xBB\xBF"), std::string::npos) << rule << line; + expect(fatal(!line.empty())) << rule << ninja; + expect(ne(line.find("= \xEF\xBB\xBF"), std::string::npos)) << rule << line; } -} +}}; -TEST(NinjaBackendEncoding, GnuResponseFilesCarryNoByteOrderMark) { +UTest gnu_response_files_carry_no_byte_order_mark{"NinjaBackendEncoding/GnuResponseFilesCarryNoByteOrderMark", [] { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/a.c", @@ -2952,40 +2933,40 @@ TEST(NinjaBackendEncoding, GnuResponseFilesCarryNoByteOrderMark) { .packageName = "objc_rule_test", }); auto ninja = emit_ninja_string(plan); - EXPECT_FALSE(rsp_line(ninja, "rule cxx_link\n").empty()) << ninja; - EXPECT_EQ(ninja.find("\xEF\xBB\xBF"), std::string::npos) << ninja; -} + expect(!(rsp_line(ninja, "rule cxx_link\n").empty())) << ninja; + expect(eq(ninja.find("\xEF\xBB\xBF"), std::string::npos)) << ninja; +}}; // Ninja reports the encoding it reads build.ninja in; mcpp writes UTF-8. -TEST(NinjaBackendEncoding, ANinjaThatReadsUtf8Passes) { - EXPECT_FALSE(ninja_encoding_mismatch("Build file encoding: UTF-8\r\n", 65001, - "ninja.exe").has_value()); - EXPECT_FALSE(ninja_encoding_mismatch("Build file encoding: UTF-8\n", 1252, - "ninja.exe").has_value()); -} - -TEST(NinjaBackendEncoding, ANinjaThatReadsAnsiIsRefusedByName) { +UTest a_ninja_that_reads_utf8_passes{"NinjaBackendEncoding/ANinjaThatReadsUtf8Passes", [] { + expect(!(ninja_encoding_mismatch("Build file encoding: UTF-8\r\n", 65001, + "ninja.exe").has_value())); + expect(!(ninja_encoding_mismatch("Build file encoding: UTF-8\n", 1252, + "ninja.exe").has_value())); +}}; + +UTest a_ninja_that_reads_ansi_is_refused_by_name{"NinjaBackendEncoding/ANinjaThatReadsAnsiIsRefusedByName", [] { auto modern = ninja_encoding_mismatch("Build file encoding: ANSI\r\n", 65001, "C:/tools/ninja.exe"); - ASSERT_TRUE(modern.has_value()); - EXPECT_NE(modern->find("C:/tools/ninja.exe"), std::string::npos) << *modern; - EXPECT_NE(modern->find("ANSI"), std::string::npos) << *modern; - EXPECT_NE(modern->find("1.11"), std::string::npos) << *modern; + expect(fatal(modern.has_value())); + if (auto&& failed = expect(ne(modern->find("C:/tools/ninja.exe"), std::string::npos)); !failed) failed << *modern; + if (auto&& failed = expect(ne(modern->find("ANSI"), std::string::npos)); !failed) failed << *modern; + if (auto&& failed = expect(ne(modern->find("1.11"), std::string::npos)); !failed) failed << *modern; // On a host that ignores the declaration the advice names the code page. auto legacy = ninja_encoding_mismatch("Build file encoding: ANSI\n", 936, "ninja.exe"); - ASSERT_TRUE(legacy.has_value()); - EXPECT_NE(legacy->find("936"), std::string::npos) << *legacy; -} + expect(fatal(legacy.has_value())); + if (auto&& failed = expect(ne(legacy->find("936"), std::string::npos)); !failed) failed << *legacy; +}}; -TEST(NinjaBackendEncoding, OutputThatNamesNoEncodingDecidesNothing) { - EXPECT_FALSE(ninja_encoding_mismatch("", 65001, "ninja").has_value()); - EXPECT_FALSE(ninja_encoding_mismatch("ninja: error: unknown tool 'wincodepage'", - 65001, "ninja").has_value()); -} +UTest output_that_names_no_encoding_decides_nothing{"NinjaBackendEncoding/OutputThatNamesNoEncodingDecidesNothing", [] { + expect(!(ninja_encoding_mismatch("", 65001, "ninja").has_value())); + expect(!(ninja_encoding_mismatch("ninja: error: unknown tool 'wincodepage'", + 65001, "ninja").has_value())); +}}; -TEST(NinjaBackend, PeExportsPatternsAndAConsumerReachTheDefEdge) { +UTest pe_exports_patterns_and_a_consumer_reach_the_def_edge{"NinjaBackend/PeExportsPatternsAndAConsumerReachTheDefEdge", [] { auto plan = minimal_plan(); plan.toolchain.compiler = mcpp::toolchain::CompilerId::MSVC; plan.toolchain.targetTriple = "x86_64-pc-windows-msvc"; @@ -3001,13 +2982,12 @@ TEST(NinjaBackend, PeExportsPatternsAndAConsumerReachTheDefEdge) { dll.objects = {"obj/probe.o"}; plan.linkUnits.push_back(dll); auto alone = emit_ninja_string(plan); - EXPECT_NE(alone.find("build bin/probe.def : coff_def obj/probe.o | obj/probe.exports.gen"), - std::string::npos) << alone; - EXPECT_NE(alone.find("--exports-file obj/probe.exports.gen"), std::string::npos); - EXPECT_EQ(alone.find("--required"), std::string::npos); + expect(ne(alone.find("build bin/probe.def : coff_def obj/probe.o | obj/probe.exports.gen"), std::string::npos)) << alone; + expect(ne(alone.find("--exports-file obj/probe.exports.gen"), std::string::npos)); + expect(eq(alone.find("--required"), std::string::npos)); std::ifstream in(plan.outputDir / "obj" / "probe.exports.gen"); std::string written((std::istreambuf_iterator(in)), std::istreambuf_iterator()); - EXPECT_EQ(written, "probe_*\n"); + expect(eq(written, "probe_*\n")); LinkUnit consumer; consumer.kind = LinkUnit::Binary; @@ -3016,16 +2996,16 @@ TEST(NinjaBackend, PeExportsPatternsAndAConsumerReachTheDefEdge) { consumer.objects = {"obj/app.o"}; consumer.implicitInputs = {"bin/probe.dll", "bin/probe.lib"}; plan.linkUnits.push_back(consumer); - EXPECT_NE(emit_ninja_string(plan).find("--required"), std::string::npos); + expect(ne(emit_ninja_string(plan).find("--required"), std::string::npos)); std::error_code ec; std::filesystem::remove_all(plan.outputDir, ec); -} +}}; // `trim_paths` PER PACKAGE (mcpp#844). The map of the whole plan sat in the // flags every unit shares, so a dependency's command line named the roots of // whichever members the plan built, and switching members recompiled it. A // unit now carries its own package's map, and the shared flags carry none. -TEST(NinjaBackend, ATrimMapIsTheUnitsPackagesAndNoOther) { +UTest a_trim_map_is_the_units_packages_and_no_other{"NinjaBackend/ATrimMapIsTheUnitsPackagesAndNoOther", [] { auto plan = minimal_plan(); plan.manifest.buildConfig.trimPaths = true; const std::filesystem::path build = "/w/target/x"; @@ -3040,28 +3020,28 @@ TEST(NinjaBackend, ATrimMapIsTheUnitsPackagesAndNoOther) { .object = "obj/main.o", .packageName = "app"}); const auto flags = compute_flags(plan); - EXPECT_EQ(flags.cxx.find("file-prefix-map"), std::string::npos) << flags.cxx; - EXPECT_EQ(flags.cc.find("file-prefix-map"), std::string::npos) << flags.cc; + expect(eq(flags.cxx.find("file-prefix-map"), std::string::npos)) << flags.cxx; + expect(eq(flags.cc.find("file-prefix-map"), std::string::npos)) << flags.cc; const auto ninja = emit_ninja_string(plan); auto edge = [&](std::string_view object) { const auto at = ninja.find(std::format("build {} :", object)); - EXPECT_NE(at, std::string::npos) << ninja; + expect(ne(at, std::string::npos)) << ninja; return ninja.substr(at, ninja.find("\nbuild ", at + 1) - at); }; const auto coreEdge = edge("obj/core.o"); - EXPECT_NE(coreEdge.find("-ffile-prefix-map=/w/core=core@0.1.0"), std::string::npos) << coreEdge; - EXPECT_NE(coreEdge.find("-ffile-prefix-map=/w/target/x=/mcpp/build"), std::string::npos) << coreEdge; - EXPECT_EQ(coreEdge.find("/w/app"), std::string::npos) << "a consumer's root reached its dependency: " << coreEdge; + expect(ne(coreEdge.find("-ffile-prefix-map=/w/core=core@0.1.0"), std::string::npos)) << coreEdge; + expect(ne(coreEdge.find("-ffile-prefix-map=/w/target/x=/mcpp/build"), std::string::npos)) << coreEdge; + expect(eq(coreEdge.find("/w/app"), std::string::npos)) << "a consumer's root reached its dependency: " << coreEdge; const auto appEdge = edge("obj/main.o"); - EXPECT_NE(appEdge.find("-ffile-prefix-map=/w/app=app@0.1.0"), std::string::npos) << appEdge; - EXPECT_NE(appEdge.find("-ffile-prefix-map=/w/core=core@0.1.0"), std::string::npos) << appEdge; -} + expect(ne(appEdge.find("-ffile-prefix-map=/w/app=app@0.1.0"), std::string::npos)) << appEdge; + expect(ne(appEdge.find("-ffile-prefix-map=/w/core=core@0.1.0"), std::string::npos)) << appEdge; +}}; // The map of a package is the fixed roots and its closure's roots, in the // order of the whole plan's map: shortest prefix first, so that a driver // trying the last mapping first takes the longest match. -TEST(NinjaBackend, ATrimMapKeepsTheFixedRootsAndTheMarkedPackages) { +UTest a_trim_map_keeps_the_fixed_roots_and_the_marked_packages{"NinjaBackend/ATrimMapKeepsTheFixedRootsAndTheMarkedPackages", [] { // Absolute on every host: `trim_map` maps absolute prefixes only, and a // path without a volume is not absolute on Windows. const auto w = std::filesystem::current_path().root_path() / "w"; @@ -3080,36 +3060,34 @@ TEST(NinjaBackend, ATrimMapKeepsTheFixedRootsAndTheMarkedPackages) { return out; }; const auto coreNames = names(core); - EXPECT_NE(std::ranges::find(coreNames, "core@0.1.0"), coreNames.end()); - EXPECT_NE(std::ranges::find(coreNames, "/mcpp/toolchain"), coreNames.end()); - EXPECT_NE(std::ranges::find(coreNames, "/mcpp/build"), coreNames.end()); - EXPECT_EQ(std::ranges::find(coreNames, "app@1.0.0"), coreNames.end()); - EXPECT_EQ(std::ranges::find(coreNames, "gui@0.2.0"), coreNames.end()); - EXPECT_EQ(names(all).size(), coreNames.size() + 2); + expect(ne(std::ranges::find(coreNames, "core@0.1.0"), coreNames.end())); + expect(ne(std::ranges::find(coreNames, "/mcpp/toolchain"), coreNames.end())); + expect(ne(std::ranges::find(coreNames, "/mcpp/build"), coreNames.end())); + expect(eq(std::ranges::find(coreNames, "app@1.0.0"), coreNames.end())); + expect(eq(std::ranges::find(coreNames, "gui@0.2.0"), coreNames.end())); + expect(eq(names(all).size(), coreNames.size() + 2)); for (std::size_t i = 1; i < core.size(); ++i) - EXPECT_LE(core[i - 1].first.native().size(), core[i].first.native().size()); -} + expect(le(core[i - 1].first.native().size(), core[i].first.native().size())); +}}; // Design 2026-10-26 §24.4 item 9: the entry symbol of a freestanding image, and // the `--defsym` that makes an undefined one a link error, through the driver // and to ld.lld itself. -TEST(NinjaBackend, StartRendersTheEntryAndItsDefinitionCheck) { +UTest start_renders_the_entry_and_its_definition_check{"NinjaBackend/StartRendersTheEntryAndItsDefinitionCheck", [] { using mcpp::build::start_link_flags; using Flags = std::vector; - EXPECT_EQ(start_link_flags("", false), Flags{}); - EXPECT_EQ(start_link_flags("_reset", false), - (Flags{"-Wl,-e,_reset", "-Wl,--defsym=__mcpp_start=_reset"})); - EXPECT_EQ(start_link_flags("_reset", true), - (Flags{"-e", "_reset", "--defsym=__mcpp_start=_reset"})); -} + expect(eq(start_link_flags("", false), Flags{})); + expect(eq(start_link_flags("_reset", false), (Flags{"-Wl,-e,_reset", "-Wl,--defsym=__mcpp_start=_reset"}))); + expect(eq(start_link_flags("_reset", true), (Flags{"-e", "_reset", "--defsym=__mcpp_start=_reset"}))); +}}; -TEST(NinjaBackend, StartFailureAdviceNamesTheStatement) { +UTest start_failure_advice_names_the_statement{"NinjaBackend/StartFailureAdviceNamesTheStatement", [] { using mcpp::build::start_failure_advice; const std::string lld = "ld.lld: error: --defsym:1: symbol not found: _missing\n"; const auto named = start_failure_advice(lld, {{"_missing", "[targets.k] start = \"_missing\""}}); - EXPECT_NE(named.find("It was named by [targets.k] start = \"_missing\""), std::string::npos) << named; + expect(ne(named.find("It was named by [targets.k] start = \"_missing\""), std::string::npos)) << named; const auto generic = start_failure_advice(lld, {}); - EXPECT_NE(generic.find("`_missing`"), std::string::npos) << generic; - EXPECT_NE(generic.find("mcpp:link-start"), std::string::npos) << generic; - EXPECT_EQ(start_failure_advice("undefined symbol: foo", {}), ""); -} + expect(ne(generic.find("`_missing`"), std::string::npos)) << generic; + expect(ne(generic.find("mcpp:link-start"), std::string::npos)) << generic; + expect(eq(start_failure_advice("undefined symbol: foo", {}), "")); +}}; diff --git a/tests/unit/test_object_address.cpp b/tests/unit/test_object_address.cpp index c60edeeca..aab2cb265 100644 --- a/tests/unit/test_object_address.cpp +++ b/tests/unit/test_object_address.cpp @@ -15,15 +15,17 @@ // a package engineered to collide with it — and demands byte-identical // addresses. It fails on the pre-#344 tree. -#include import std; +import mcpplibs.mtest; import mcpp.build.plan; import mcpp.manifest; import mcpp.modgraph.graph; import mcpp.modgraph.scanner; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + using namespace mcpp::build; namespace { @@ -137,42 +139,42 @@ std::optional planZlib(const Tmp& t, bool withBzip2, std::string* err) { // The load-bearing assertion. Adding an unrelated, deliberately colliding // package to the graph must not move zlib's object by one byte — not its build // path, and above all not its cache address. -TEST(ObjectAddress, DependencyAddressesAreImmuneToTheRestOfTheGraph) { +UTest dependency_addresses_are_immune_to_the_rest_of_the_graph{"ObjectAddress/DependencyAddressesAreImmuneToTheRestOfTheGraph", [] { Tmp t; std::string errAlone, errBeside; auto alone = planZlib(t, /*withBzip2=*/false, &errAlone); auto beside = planZlib(t, /*withBzip2=*/true, &errBeside); - ASSERT_TRUE(alone) << errAlone; - ASSERT_TRUE(beside) << errBeside; + expect(fatal(bool(alone))) << errAlone; + expect(fatal(bool(beside))) << errBeside; - EXPECT_EQ(alone->packageObjectRel, beside->packageObjectRel) + expect(eq(alone->packageObjectRel, beside->packageObjectRel)) << "the cache-entry address moved when an unrelated package joined the " "graph — that is mcpp#344"; - EXPECT_EQ(alone->object, beside->object); -} + expect(eq(alone->object, beside->object)); +}}; // The address must also be package-internal: nothing about the consumer, and // nothing that only exists on this machine. It is read back by another machine // that computed the same key. -TEST(ObjectAddress, DependencyCacheAddressIsPackageInternal) { +UTest dependency_cache_address_is_package_internal{"ObjectAddress/DependencyCacheAddressIsPackageInternal", [] { Tmp t; std::string err; auto built = planZlib(t, /*withBzip2=*/true, &err); - ASSERT_TRUE(built) << err; + expect(fatal(bool(built))) << err; auto rel = built->packageObjectRel.generic_string(); - EXPECT_FALSE(rel.empty()); - EXPECT_FALSE(built->packageObjectRel.is_absolute()) << rel; - EXPECT_FALSE(rel.starts_with("..")) << rel; + expect(!rel.empty()); + expect(!(built->packageObjectRel.is_absolute())) << rel; + expect(!rel.starts_with("..")) << rel; // Mirrors the source's path relative to its OWN package root, and carries // no package-partition component (that lives in the build path only). - EXPECT_EQ(rel, "zlib-1.3.2/compress.o") << rel; + expect(eq(rel, "zlib-1.3.2/compress.o")) << rel; // The build path does partition by package — that is what makes the // cross-package census unnecessary. auto obj = built->object.generic_string(); - EXPECT_EQ(obj, "obj/compat_zlib/zlib-1.3.2/compress.o") << obj; -} + expect(eq(obj, "obj/compat_zlib/zlib-1.3.2/compress.o")) << obj; +}}; // `path_is_under_any` decides both whether a package may be cached and where // its objects are anchored, and it must answer LEXICALLY. @@ -184,7 +186,7 @@ TEST(ObjectAddress, DependencyCacheAddressIsPackageInternal) { // which point `/registry/data/xpkgs/` no longer looks like it is in // the store — every such package silently drops out of the build cache. That // regression was caught by e2e 40 and is pinned here where it is cheap. -TEST(ObjectAddress, PathContainmentIsLexicalSoSymlinkedStoresStillCount) { +UTest path_containment_is_lexical_so_symlinked_stores_still_count{"ObjectAddress/PathContainmentIsLexicalSoSymlinkedStoresStillCount", [] { Tmp t; auto real = t.path / "real-store"; auto store = t.path / "home" / "registry" / "data" / "xpkgs"; @@ -194,23 +196,23 @@ TEST(ObjectAddress, PathContainmentIsLexicalSoSymlinkedStoresStillCount) { std::error_code ec; std::filesystem::create_directory_symlink( real / "compat.zlib@1.3.2", store / "compat.zlib@1.3.2", ec); - if (ec) GTEST_SKIP() << "symlinks unavailable: " << ec.message(); + if (ec) skip([&] { std::ostringstream text; text << "symlinks unavailable: " << ec.message(); return text.str(); }()); auto pkgRoot = store / "compat.zlib@1.3.2"; - EXPECT_TRUE(path_is_under_any(pkgRoot, {store})); - EXPECT_TRUE(path_is_under_any(pkgRoot / "src" / "compress.c", {store})); + expect(path_is_under_any(pkgRoot, {store})); + expect(path_is_under_any(pkgRoot / "src" / "compress.c", {store})); // And it still says no to something genuinely outside — the gate has to // keep rejecting `target/.mangled/**`, which is the case it exists for. - EXPECT_FALSE(path_is_under_any(t.path / "proj" / "target" / ".mangled" / "x", + expect(!path_is_under_any(t.path / "proj" / "target" / ".mangled" / "x", {store})); - EXPECT_FALSE(path_is_under_any(pkgRoot, {})); -} + expect(!path_is_under_any(pkgRoot, {})); +}}; // The root project is never cached, so it keeps the flat layout every project // has had since 0.0.1 — and a dependency shipping a same-named file can no // longer force it to disambiguate. -TEST(ObjectAddress, RootObjectsStayFlatAndUncacheable) { +UTest root_objects_stay_flat_and_uncacheable{"ObjectAddress/RootObjectsStayFlatAndUncacheable", [] { Tmp t; auto projectRoot = t.path / "proj"; auto storeRoot = t.path / "store"; @@ -240,16 +242,16 @@ TEST(ObjectAddress, RootObjectsStayFlatAndUncacheable) { auto plan = make_plan(rootManifest, gccLike(), {}, graph, topo, packages, projectRoot, projectRoot / "target" / "t", {}, {}, {storeRoot}); - ASSERT_TRUE(plan) << plan.error(); + if (auto&& failed = expect(fatal(bool(plan))); !failed) failed << plan.error(); for (auto& cu : plan->compileUnits) { if (cu.source == projectRoot / "src" / "compress.c") { - EXPECT_EQ(cu.object.generic_string(), "obj/compress.o"); - EXPECT_TRUE(cu.packageObjectRel.empty()) + expect(eq(cu.object.generic_string(), "obj/compress.o")); + expect(cu.packageObjectRel.empty()) << "the root project must never get a cache address"; } } -} +}}; // ── A source outside the package that declares it (mcpp#641, item 3) ─────── @@ -313,25 +315,25 @@ std::optional addressOfDeclaredDependencySource( // The address names the package that contains the file and the path inside it, // so it does not grow with the distance between the two package roots. -TEST(ObjectAddress, ASourceUnderAnotherPackageIsAddressedByThatPackage) { +UTest a_source_under_another_package_is_addressed_by_that_package{"ObjectAddress/ASourceUnderAnotherPackageIsAddressedByThatPackage", [] { Tmp t; const auto dep = t.path / "fw" / "dep"; std::string err; auto near = addressOfDeclaredDependencySource(t.path / "fw" / "tool", dep, &err); - ASSERT_TRUE(near) << err; - EXPECT_EQ(*near, "obj/installer/__pkg/dep/platform/impl/helper.o"); - EXPECT_EQ(near->find("__up"), std::string::npos) << *near; + expect(fatal(bool(near))) << err; + expect(eq(*near, "obj/installer/__pkg/dep/platform/impl/helper.o")); + if (auto&& failed = expect(eq(near->find("__up"), std::string::npos)); !failed) failed << *near; auto deeper = addressOfDeclaredDependencySource( t.path / "fw" / "tools" / "windows" / "installer" / "tool", dep, &err); - ASSERT_TRUE(deeper) << err; - EXPECT_EQ(*deeper, *near) + expect(fatal(bool(deeper))) << err; + expect(eq(*deeper, *near)) << "moving the declaring package deeper changed the address"; -} +}}; // A source no package contains, colliding by basename with a root source, is // filed under a hash of its directory rather than a climbing mirror of it. -TEST(ObjectAddress, AnUnownedEscapingSourceIsAddressedByAHashOfItsDirectory) { +UTest an_unowned_escaping_source_is_addressed_by_a_hash_of_its_directory{"ObjectAddress/AnUnownedEscapingSourceIsAddressedByAHashOfItsDirectory", [] { Tmp t; auto projectRoot = t.path / "proj"; auto depRoot = t.path / "store" / "dep@1.0.0"; @@ -354,23 +356,23 @@ TEST(ObjectAddress, AnUnownedEscapingSourceIsAddressedByAHashOfItsDirectory) { std::vector topo{0, 1}; auto plan = make_plan(rootManifest, gccLike(), {}, graph, topo, packages, projectRoot, projectRoot / "target" / "t", {}, {}, {}); - ASSERT_TRUE(plan) << plan.error(); + if (auto&& failed = expect(fatal(bool(plan))); !failed) failed << plan.error(); std::string rootObj, outsideObj; for (auto& cu : plan->compileUnits) { if (cu.source == projectRoot / "src" / "gen.cpp") rootObj = cu.object.generic_string(); if (cu.source == outside) outsideObj = cu.object.generic_string(); } - EXPECT_EQ(rootObj, "obj/app/src/gen.o"); - ASSERT_TRUE(outsideObj.starts_with("obj/dep/__ext/")) << outsideObj; - EXPECT_TRUE(outsideObj.ends_with("/gen.o")) << outsideObj; - EXPECT_EQ(outsideObj.find("__up"), std::string::npos) << outsideObj; + expect(eq(rootObj, "obj/app/src/gen.o")); + expect(fatal(outsideObj.starts_with("obj/dep/__ext/"))) << outsideObj; + expect(outsideObj.ends_with("/gen.o")) << outsideObj; + expect(eq(outsideObj.find("__up"), std::string::npos)) << outsideObj; const auto hash = outsideObj.substr(std::string("obj/dep/__ext/").size(), 8); - EXPECT_EQ(outsideObj.size(), std::string("obj/dep/__ext/").size() + 8 + std::string("/gen.o").size()) + expect(eq(outsideObj.size(), std::string("obj/dep/__ext/").size() + 8 + std::string("/gen.o").size())) << outsideObj; - EXPECT_TRUE(std::ranges::all_of(hash, [](char c) { + expect(std::ranges::all_of(hash, [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); })) << outsideObj; -} +}}; // A WORKSPACE PLAN'S ROOT OWNS NO SOURCE (pack drive and selection design // 2026-10-01, B1; mcpp#751). A member lists a file that lies in the workspace @@ -378,7 +380,7 @@ TEST(ObjectAddress, AnUnownedEscapingSourceIsAddressedByAHashOfItsDirectory) { // workspace's, and owning the file put it in the root's basename census, so // its object moved when a second member listed the same file: `-p` and // `--workspace` compiled it to two addresses in one build directory. -TEST(ObjectAddress, AMembersOutsideSourceIsImmuneToAnotherMemberListingIt) { +UTest a_members_outside_source_is_immune_to_another_member_listing_it{"ObjectAddress/AMembersOutsideSourceIsImmuneToAnotherMemberListingIt", [] { Tmp t; const auto ws = t.path / "ws"; auto plan_with = [&](bool withTool) -> std::filesystem::path { @@ -401,25 +403,25 @@ TEST(ObjectAddress, AMembersOutsideSourceIsImmuneToAnotherMemberListingIt) { for (std::size_t i = 0; i < graph.units.size(); ++i) topo.push_back(i); auto plan = make_plan(root, gccLike(), {}, graph, topo, packages, ws, ws / "target" / "t", {}, {}, {}); - EXPECT_TRUE(plan) << (plan ? std::string{} : plan.error()); + if (auto&& failed = expect(bool(plan)); !failed) failed << (plan ? std::string{} : plan.error()); if (!plan) return {}; for (auto const& cu : plan->compileUnits) if (cu.packageName == "core") return cu.object; - ADD_FAILURE() << "core's unit is not in the plan"; + expect(false) << "core's unit is not in the plan"; return {}; }; const auto alone = plan_with(false); const auto beside = plan_with(true); - EXPECT_FALSE(alone.empty()); - EXPECT_EQ(alone, beside); + expect(!alone.empty()); + expect(eq(alone, beside)); // Filed under its declaring member, not at the root's flat address. - EXPECT_NE(alone.generic_string().find("obj/core/"), std::string::npos) << alone; -} + expect(ne(alone.generic_string().find("obj/core/"), std::string::npos)) << alone; +}}; // The same for a file inside ANOTHER member's directory: the member that // contains it may be outside the selection, and then the virtual root contains // the file instead. The address is the file's place in the workspace. -TEST(ObjectAddress, AMembersSourceInAnotherMembersDirectoryIsImmuneToTheSelection) { +UTest a_members_source_in_another_members_directory_is_immune_to_the_selection{"ObjectAddress/AMembersSourceInAnotherMembersDirectoryIsImmuneToTheSelection", [] { Tmp t; const auto ws = t.path / "ws"; auto plan_with = [&](bool withLib) -> std::filesystem::path { @@ -442,16 +444,16 @@ TEST(ObjectAddress, AMembersSourceInAnotherMembersDirectoryIsImmuneToTheSelectio for (std::size_t i = 0; i < graph.units.size(); ++i) topo.push_back(i); auto plan = make_plan(root, gccLike(), {}, graph, topo, packages, ws, ws / "target" / "t", {}, {}, {}); - EXPECT_TRUE(plan) << (plan ? std::string{} : plan.error()); + if (auto&& failed = expect(bool(plan)); !failed) failed << (plan ? std::string{} : plan.error()); if (!plan) return {}; for (auto const& cu : plan->compileUnits) if (cu.packageName == "app") return cu.object; - ADD_FAILURE() << "app's unit is not in the plan"; + expect(false) << "app's unit is not in the plan"; return {}; }; const auto alone = plan_with(false); const auto beside = plan_with(true); - EXPECT_FALSE(alone.empty()); - EXPECT_EQ(alone, beside); - EXPECT_NE(alone.generic_string().find("obj/app/__ws/lib/src/"), std::string::npos) << alone; -} + expect(!alone.empty()); + expect(eq(alone, beside)); + expect(ne(alone.generic_string().find("obj/app/__ws/lib/src/"), std::string::npos)) << alone; +}}; diff --git a/tests/unit/test_p1689.cpp b/tests/unit/test_p1689.cpp index da49c6947..75957b71b 100644 --- a/tests/unit/test_p1689.cpp +++ b/tests/unit/test_p1689.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.modgraph.p1689; +using namespace mcpplibs::mtest; + using namespace mcpp::modgraph::p1689; namespace { @@ -58,36 +60,36 @@ constexpr const char* kEmptyRequires = R"({ } // namespace -TEST(P1689Parse, SimpleProvider) { +UTest simple_provider{"P1689Parse/SimpleProvider", [] { auto r = parse_ddi(kSimpleProvider); - ASSERT_TRUE(r) << r.error(); - EXPECT_EQ(r->primaryOutput, "/tmp/foo.o"); - ASSERT_EQ(r->provides.size(), 1u); - EXPECT_EQ(r->provides[0].logicalName, "foo"); + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << r.error(); + expect(eq(r->primaryOutput, "/tmp/foo.o")); + expect(fatal(eq(r->provides.size(), 1u))); + expect(eq(r->provides[0].logicalName, "foo")); // Not EXPECT_TRUE: on an optional that only asserts "the key was present", // which stays green when the value parses as false. - EXPECT_EQ(r->provides[0].isInterface, std::optional{true}); - ASSERT_EQ(r->requires_.size(), 2u); - EXPECT_EQ(r->requires_[0], "std"); - EXPECT_EQ(r->requires_[1], "foo:impl"); -} + expect(eq(r->provides[0].isInterface, std::optional{true})); + expect(fatal(eq(r->requires_.size(), 2u))); + expect(eq(r->requires_[0], "std")); + expect(eq(r->requires_[1], "foo:impl")); +}}; -TEST(P1689Parse, PureConsumer) { +UTest pure_consumer{"P1689Parse/PureConsumer", [] { auto r = parse_ddi(kPureConsumer); - ASSERT_TRUE(r) << r.error(); - EXPECT_TRUE(r->provides.empty()); - ASSERT_EQ(r->requires_.size(), 2u); - EXPECT_EQ(r->requires_[0], "std"); - EXPECT_EQ(r->requires_[1], "myapp.lib"); -} - -TEST(P1689Parse, EmptyRequires) { + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << r.error(); + expect(r->provides.empty()); + expect(fatal(eq(r->requires_.size(), 2u))); + expect(eq(r->requires_[0], "std")); + expect(eq(r->requires_[1], "myapp.lib")); +}}; + +UTest empty_requires{"P1689Parse/EmptyRequires", [] { auto r = parse_ddi(kEmptyRequires); - ASSERT_TRUE(r) << r.error(); - ASSERT_EQ(r->provides.size(), 1u); - EXPECT_EQ(r->provides[0].logicalName, "lone"); - EXPECT_TRUE(r->requires_.empty()); -} + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << r.error(); + expect(fatal(eq(r->provides.size(), 1u))); + expect(eq(r->provides[0].logicalName, "lone")); + expect(r->requires_.empty()); +}}; // ─── is-interface decides whether a source may be published ──────────────── // @@ -116,34 +118,34 @@ constexpr const char* kNoIsInterfaceKey = R"({ ] })"; -TEST(P1689Parse, ImplementationPartitionIsNotAnInterface) { +UTest implementation_partition_is_not_an_interface{"P1689Parse/ImplementationPartitionIsNotAnInterface", [] { auto r = parse_ddi(kImplementationPartition); - ASSERT_TRUE(r) << r.error(); - ASSERT_EQ(r->provides.size(), 1u); - EXPECT_EQ(r->provides[0].logicalName, "mathkit:secret"); - EXPECT_EQ(r->provides[0].isInterface, std::optional{false}); -} + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << r.error(); + expect(fatal(eq(r->provides.size(), 1u))); + expect(eq(r->provides[0].logicalName, "mathkit:secret")); + expect(eq(r->provides[0].isInterface, std::optional{false})); +}}; -TEST(P1689Parse, AnAbsentIsInterfaceKeyStaysAbsent) { +UTest an_absent_is_interface_key_stays_absent{"P1689Parse/AnAbsentIsInterfaceKeyStaysAbsent", [] { // P1689 makes the key optional, so absence has to reach the packer as // "nobody said" rather than as either answer. It used to arrive as `false` // by struct default — which is the same value as an explicit // implementation partition, i.e. the two became indistinguishable. auto r = parse_ddi(kNoIsInterfaceKey); - ASSERT_TRUE(r) << r.error(); - ASSERT_EQ(r->provides.size(), 1u); - EXPECT_EQ(r->provides[0].logicalName, "mathkit:quiet"); - EXPECT_FALSE(r->provides[0].isInterface.has_value()); -} + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << r.error(); + expect(fatal(eq(r->provides.size(), 1u))); + expect(eq(r->provides[0].logicalName, "mathkit:quiet")); + expect(!(r->provides[0].isInterface.has_value())); +}}; -TEST(P1689Parse, RejectsNonObject) { +UTest rejects_non_object{"P1689Parse/RejectsNonObject", [] { auto r = parse_ddi("[]"); - EXPECT_FALSE(r); - EXPECT_NE(r.error().find("top-level"), std::string::npos); -} + expect(!r); + expect(ne(r.error().find("top-level"), std::string::npos)); +}}; -TEST(P1689Parse, RejectsMissingRules) { +UTest rejects_missing_rules{"P1689Parse/RejectsMissingRules", [] { auto r = parse_ddi(R"({"version": 0})"); - EXPECT_FALSE(r); - EXPECT_NE(r.error().find("rules"), std::string::npos); -} + expect(!r); + expect(ne(r.error().find("rules"), std::string::npos)); +}}; diff --git a/tests/unit/test_pack_abi_tag.cpp b/tests/unit/test_pack_abi_tag.cpp index 8236827ad..374583ea6 100644 --- a/tests/unit/test_pack_abi_tag.cpp +++ b/tests/unit/test_pack_abi_tag.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pack.abi_tag; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + using namespace mcpp::pack; using mcpp::toolchain::CompilerId; using mcpp::toolchain::Toolchain; @@ -25,142 +27,142 @@ Toolchain tc(CompilerId cc, std::string ver, std::string stdlib, std::string std // ─── the tag is a projection, and of the CANONICAL triple ────────────────── -TEST(AbiTag, CxxSurfaceNamesEveryDimension) { +UTest cxx_surface_names_every_dimension{"AbiTag/CxxSurfaceNamesEveryDimension", [] { auto t = cxx_surface_tag(tc(CompilerId::GCC, "16.1.0", "libstdc++", "16.1.0"), "x86_64-linux-gnu", 23); - EXPECT_EQ(t.str(), "x86_64-linux-gnu-gcc16-libstdcxx16-c++23"); - EXPECT_FALSE(t.c_surface()); -} + expect(eq(t.str(), "x86_64-linux-gnu-gcc16-libstdcxx16-c++23")); + expect(!t.c_surface()); +}}; -TEST(AbiTag, UsesTheCanonicalTripleNotTheCompilerReportedOne) { +UTest uses_the_canonical_triple_not_the_compiler_reported_one{"AbiTag/UsesTheCanonicalTripleNotTheCompilerReportedOne", [] { // The toolchain above reports `x86_64-w64-mingw32`; mcpp's target // vocabulary — and every `[target.'']` key a package can be // selected by — says `x86_64-windows-gnu`. Publishing the compiler's // spelling would give one decision two spellings. auto t = cxx_surface_tag(tc(CompilerId::GCC, "16.1.0", "libstdc++", "16.1.0"), "x86_64-windows-gnu", 23); - EXPECT_EQ(t.triple, "x86_64-windows-gnu"); - EXPECT_EQ(t.str().find("mingw"), std::string::npos); -} - -TEST(AbiTag, LibcxxAndMsvcStlTokenize) { - EXPECT_EQ(stdlib_token("libstdc++"), "libstdcxx"); - EXPECT_EQ(stdlib_token("libc++"), "libcxx"); - EXPECT_EQ(stdlib_token("msvc-stl"), "msvcstl"); - EXPECT_EQ(stdlib_token(""), "unknownstl"); -} - -TEST(AbiTag, MajorIsLeadingDigitsOnly) { - EXPECT_EQ(major_of("16.1.0"), "16"); - EXPECT_EQ(major_of("19.44.35207"), "19"); - EXPECT_EQ(major_of("22"), "22"); - EXPECT_EQ(major_of(""), "0"); -} + expect(eq(t.triple, "x86_64-windows-gnu")); + expect(eq(t.str().find("mingw"), std::string::npos)); +}}; + +UTest libcxx_and_msvc_stl_tokenize{"AbiTag/LibcxxAndMsvcStlTokenize", [] { + expect(eq(stdlib_token("libstdc++"), "libstdcxx")); + expect(eq(stdlib_token("libc++"), "libcxx")); + expect(eq(stdlib_token("msvc-stl"), "msvcstl")); + expect(eq(stdlib_token(""), "unknownstl")); +}}; + +UTest major_is_leading_digits_only{"AbiTag/MajorIsLeadingDigitsOnly", [] { + expect(eq(major_of("16.1.0"), "16")); + expect(eq(major_of("19.44.35207"), "19")); + expect(eq(major_of("22"), "22")); + expect(eq(major_of(""), "0")); +}}; // ─── the SHAPE is the surface: a C library publishes a shorter tag ───────── -TEST(AbiTag, CSurfaceIsTripleOnly) { +UTest c_surface_is_triple_only{"AbiTag/CSurfaceIsTripleOnly", [] { auto t = c_surface_tag("x86_64-linux-gnu"); - EXPECT_EQ(t.str(), "x86_64-linux-gnu"); - EXPECT_TRUE(t.c_surface()); -} + expect(eq(t.str(), "x86_64-linux-gnu")); + expect(bool(t.c_surface())); +}}; -TEST(AbiTag, CSurfaceAcceptsAnyCompilerAndStdlib) { +UTest c_surface_accepts_any_compiler_and_stdlib{"AbiTag/CSurfaceAcceptsAnyCompilerAndStdlib", [] { auto published = c_surface_tag("x86_64-linux-gnu"); auto current = cxx_surface_tag(tc(CompilerId::Clang, "22.1.8", "libc++", "22.1.8"), "x86_64-linux-gnu", 26); // An extern "C" library constrains the libc ABI and nothing else, so an // unspecified dimension is don't-care — the same rule abi_check uses. - EXPECT_TRUE(tag_check(published, current).empty()); -} + expect(tag_check(published, current).empty()); +}}; -TEST(AbiTag, CSurfaceStillRefusesAForeignTriple) { +UTest c_surface_still_refuses_a_foreign_triple{"AbiTag/CSurfaceStillRefusesAForeignTriple", [] { auto published = c_surface_tag("x86_64-linux-gnu"); auto current = c_surface_tag("aarch64-linux-gnu"); auto bad = tag_check(published, current); - ASSERT_EQ(bad.size(), 1u); - EXPECT_EQ(bad[0].dimension, "triple"); -} + expect(fatal(eq(bad.size(), 1u))); + expect(eq(bad[0].dimension, "triple")); +}}; // ─── round-trip: parsing runs from the END, because triples vary in length ── -TEST(AbiTag, ParsesFullTagBackWithATwoDashTriple) { +UTest parses_full_tag_back_with_a_two_dash_triple{"AbiTag/ParsesFullTagBackWithATwoDashTriple", [] { auto t = parse_abi_tag("x86_64-linux-gnu-gcc16-libstdcxx16-c++23"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->triple, "x86_64-linux-gnu"); - EXPECT_EQ(t->compiler, "gcc16"); - EXPECT_EQ(t->stdlib, "libstdcxx16"); - EXPECT_EQ(t->standard, "c++23"); -} - -TEST(AbiTag, ParsesFullTagBackWithAOneDashTriple) { + expect(fatal(t.has_value())); + expect(eq(t->triple, "x86_64-linux-gnu")); + expect(eq(t->compiler, "gcc16")); + expect(eq(t->stdlib, "libstdcxx16")); + expect(eq(t->standard, "c++23")); +}}; + +UTest parses_full_tag_back_with_a_one_dash_triple{"AbiTag/ParsesFullTagBackWithAOneDashTriple", [] { // `aarch64-macos` has one dash, `x86_64-linux-gnu` has two. Splitting from // the front cannot tell where the triple ends; splitting from the back can, // because the C++ half is exactly three segments ending in c++NN. auto t = parse_abi_tag("aarch64-macos-llvm22-libcxx22-c++23"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->triple, "aarch64-macos"); - EXPECT_EQ(t->compiler, "llvm22"); - EXPECT_EQ(t->standard, "c++23"); -} + expect(fatal(t.has_value())); + expect(eq(t->triple, "aarch64-macos")); + expect(eq(t->compiler, "llvm22")); + expect(eq(t->standard, "c++23")); +}}; -TEST(AbiTag, ParsesATripleOnlyTag) { +UTest parses_a_triple_only_tag{"AbiTag/ParsesATripleOnlyTag", [] { auto t = parse_abi_tag("x86_64-linux-gnu"); - ASSERT_TRUE(t.has_value()); - EXPECT_TRUE(t->c_surface()); - EXPECT_EQ(t->triple, "x86_64-linux-gnu"); -} + expect(fatal(t.has_value())); + expect(bool(t->c_surface())); + expect(eq(t->triple, "x86_64-linux-gnu")); +}}; -TEST(AbiTag, RoundTripsEveryShape) { +UTest round_trips_every_shape{"AbiTag/RoundTripsEveryShape", [] { for (auto s : { "x86_64-linux-gnu", "x86_64-linux-musl-gcc16-libstdcxx16-c++23", "aarch64-macos-llvm22-libcxx22-c++26", "x86_64-windows-msvc-msvc19-msvcstl19-c++23" }) { auto t = parse_abi_tag(s); - ASSERT_TRUE(t.has_value()) << s; - EXPECT_EQ(t->str(), s); + expect(fatal(t.has_value())) << s; + expect(eq(t->str(), s)); } -} +}}; -TEST(AbiTag, RejectsEmptyAndNonTagInput) { - EXPECT_FALSE(parse_abi_tag("").has_value()); +UTest rejects_empty_and_non_tag_input{"AbiTag/RejectsEmptyAndNonTagInput", [] { + expect(!(parse_abi_tag("").has_value())); // Three trailing segments that do not end in c++NN are part of the triple, // not a C++ half — this must not be silently mis-split. auto t = parse_abi_tag("a-b-c-d"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->triple, "a-b-c-d"); - EXPECT_TRUE(t->c_surface()); -} + expect(fatal(t.has_value())); + expect(eq(t->triple, "a-b-c-d")); + expect(bool(t->c_surface())); +}}; // ─── the gate ────────────────────────────────────────────────────────────── -TEST(AbiTag, RefusesADifferentCompilerMajor) { +UTest refuses_a_different_compiler_major{"AbiTag/RefusesADifferentCompilerMajor", [] { auto published = *parse_abi_tag("x86_64-linux-gnu-gcc15-libstdcxx15-c++23"); auto current = *parse_abi_tag("x86_64-linux-gnu-gcc16-libstdcxx16-c++23"); auto bad = tag_check(published, current); - ASSERT_EQ(bad.size(), 2u); - EXPECT_EQ(bad[0].dimension, "compiler"); - EXPECT_EQ(bad[0].need, "gcc15"); - EXPECT_EQ(bad[0].got, "gcc16"); - EXPECT_EQ(bad[1].dimension, "stdlib"); -} - -TEST(AbiTag, StandardIsAFloorNotAnEquality) { + expect(fatal(eq(bad.size(), 2u))); + expect(eq(bad[0].dimension, "compiler")); + expect(eq(bad[0].need, "gcc15")); + expect(eq(bad[0].got, "gcc16")); + expect(eq(bad[1].dimension, "stdlib")); +}}; + +UTest standard_is_a_floor_not_an_equality{"AbiTag/StandardIsAFloorNotAnEquality", [] { auto published = *parse_abi_tag("x86_64-linux-gnu-gcc16-libstdcxx16-c++23"); // Building at a HIGHER level is fine: the interface being compiled is the // artifact's own source and a newer level accepts it. auto higher = *parse_abi_tag("x86_64-linux-gnu-gcc16-libstdcxx16-c++26"); - EXPECT_TRUE(tag_check(published, higher).empty()); + expect(tag_check(published, higher).empty()); // Lower is not: the interface may use syntax this level lacks. auto lower = *parse_abi_tag("x86_64-linux-gnu-gcc16-libstdcxx16-c++20"); auto bad = tag_check(published, lower); - ASSERT_EQ(bad.size(), 1u); - EXPECT_EQ(bad[0].dimension, "standard"); -} + expect(fatal(eq(bad.size(), 1u))); + expect(eq(bad[0].dimension, "standard")); +}}; -TEST(AbiTag, ReportsEveryMismatchAtOnce) { +UTest reports_every_mismatch_at_once{"AbiTag/ReportsEveryMismatchAtOnce", [] { // One diagnostic, not one per rebuild. auto published = *parse_abi_tag("aarch64-linux-gnu-gcc15-libcxx15-c++26"); auto current = *parse_abi_tag("x86_64-linux-gnu-gcc16-libstdcxx16-c++23"); - EXPECT_EQ(tag_check(published, current).size(), 4u); -} + expect(eq(tag_check(published, current).size(), 4u)); +}}; diff --git a/tests/unit/test_pack_archive_remove.cpp b/tests/unit/test_pack_archive_remove.cpp index a3a718703..944ccbd03 100644 --- a/tests/unit/test_pack_archive_remove.cpp +++ b/tests/unit/test_pack_archive_remove.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pack.library; import mcpp.toolchain.dialect; +using namespace mcpplibs::mtest; + using mcpp::pack::archive_remove_command; using Argv = std::vector; using mcpp::toolchain::gnu_dialect; @@ -34,49 +36,49 @@ const std::vector kMembers{ "mathkit.m.o", "api.m.o" }; } // namespace -TEST(ArchiveRemoveCommand, GnuTakesOneVerbThenTheArchiveThenEveryMember) { +UTest gnu_takes_one_verb_then_the_archive_then_every_member{"ArchiveRemoveCommand/GnuTakesOneVerbThenTheArchiveThenEveryMember", [] { auto cmd = archive_remove_command("/usr/bin/ar", "bin/libmathkit.a", kMembers, gnu_dialect().archiveRemoveArg, gnu_dialect().archiveRemoveTakesArchiveFirst); - EXPECT_EQ(cmd, (Argv{ "/usr/bin/ar", "d", "bin/libmathkit.a", "mathkit.m.o", "api.m.o" })); -} + expect(eq(cmd, (Argv{ "/usr/bin/ar", "d", "bin/libmathkit.a", "mathkit.m.o", "api.m.o" }))); +}}; -TEST(ArchiveRemoveCommand, MsvcTakesOneFlagPerMemberAndTheArchiveLast) { +UTest msvc_takes_one_flag_per_member_and_the_archive_last{"ArchiveRemoveCommand/MsvcTakesOneFlagPerMemberAndTheArchiveLast", [] { auto cmd = archive_remove_command("lib.exe", "bin/mathkit.lib", kMembers, msvc_dialect().archiveRemoveArg, msvc_dialect().archiveRemoveTakesArchiveFirst); - EXPECT_EQ(cmd, (Argv{ "lib.exe", "/REMOVE:mathkit.m.o", "/REMOVE:api.m.o", "bin/mathkit.lib" })); -} + expect(eq(cmd, (Argv{ "lib.exe", "/REMOVE:mathkit.m.o", "/REMOVE:api.m.o", "bin/mathkit.lib" }))); +}}; // The two rows in dialect.cppm are the input to the assembly above. Pinned here // too, so changing one without the other fails rather than producing a command // that is well-formed for the wrong archiver. -TEST(ArchiveRemoveCommand, TheDialectRowsSupplyTheseTwoSpellings) { - EXPECT_EQ(gnu_dialect().archiveRemoveArg, "d"); - EXPECT_TRUE(gnu_dialect().archiveRemoveTakesArchiveFirst); - EXPECT_EQ(msvc_dialect().archiveRemoveArg, "/REMOVE:{}"); - EXPECT_FALSE(msvc_dialect().archiveRemoveTakesArchiveFirst); -} +UTest the_dialect_rows_supply_these_two_spellings{"ArchiveRemoveCommand/TheDialectRowsSupplyTheseTwoSpellings", [] { + expect(eq(gnu_dialect().archiveRemoveArg, "d")); + expect(bool(gnu_dialect().archiveRemoveTakesArchiveFirst)); + expect(eq(msvc_dialect().archiveRemoveArg, "/REMOVE:{}")); + expect(!(msvc_dialect().archiveRemoveTakesArchiveFirst)); +}}; // The failure that motivated the seam: the packer used to emit `ar` syntax // unconditionally. Stated as what must NOT appear, because "it contains // /REMOVE:" alone would stay green for a command that also carried the `d` verb // or put the archive in the wrong place. -TEST(ArchiveRemoveCommand, TheMsvcFormCarriesNoArVerbAndNoLeadingArchive) { +UTest the_msvc_form_carries_no_ar_verb_and_no_leading_archive{"ArchiveRemoveCommand/TheMsvcFormCarriesNoArVerbAndNoLeadingArchive", [] { auto cmd = archive_remove_command("lib.exe", "bin/mathkit.lib", kMembers, msvc_dialect().archiveRemoveArg, msvc_dialect().archiveRemoveTakesArchiveFirst); - EXPECT_EQ(std::ranges::find(cmd, "d"), cmd.end()); - EXPECT_LT(std::ranges::find(cmd, "/REMOVE:mathkit.m.o"), std::ranges::find(cmd, "bin/mathkit.lib")); -} + expect(eq(std::ranges::find(cmd, "d"), cmd.end())); + expect(lt(std::ranges::find(cmd, "/REMOVE:mathkit.m.o"), std::ranges::find(cmd, "bin/mathkit.lib"))); +}}; -TEST(ArchiveRemoveCommand, ASingleMemberIsSpelledTheSameWayAsMany) { +UTest a_single_member_is_spelled_the_same_way_as_many{"ArchiveRemoveCommand/ASingleMemberIsSpelledTheSameWayAsMany", [] { // The per-member branch is a loop; a one-element list is where an // "and-then-the-rest" bug hides. auto gnu = archive_remove_command("ar", "libx.a", { "only.m.o" }, gnu_dialect().archiveRemoveArg, true); - EXPECT_EQ(gnu, (Argv{ "ar", "d", "libx.a", "only.m.o" })); + expect(eq(gnu, (Argv{ "ar", "d", "libx.a", "only.m.o" }))); auto msvc = archive_remove_command("lib.exe", "x.lib", { "only.m.o" }, msvc_dialect().archiveRemoveArg, false); - EXPECT_EQ(msvc, (Argv{ "lib.exe", "/REMOVE:only.m.o", "x.lib" })); -} + expect(eq(msvc, (Argv{ "lib.exe", "/REMOVE:only.m.o", "x.lib" }))); +}}; diff --git a/tests/unit/test_pack_binfmt.cpp b/tests/unit/test_pack_binfmt.cpp index 175035b1d..5bfd2cc99 100644 --- a/tests/unit/test_pack_binfmt.cpp +++ b/tests/unit/test_pack_binfmt.cpp @@ -1,11 +1,13 @@ -#include #include // stderr is a MACRO — `import std;` cannot export it #include // getenv import std; +import mcpplibs.mtest; import mcpp.pack.binfmt; import mcpp.pack.zip; +using namespace mcpplibs::mtest; + namespace bf = mcpp::pack::binfmt; // WHY THE FIXTURES ARE SYNTHESIZED RATHER THAN CHECKED IN. @@ -351,28 +353,28 @@ std::string macho_fat(const std::vector>& } // namespace -TEST(PackBinfmt, IdentifiesElfWithoutRunningIt) { +UTest identifies_elf_without_running_it{"PackBinfmt/IdentifiesElfWithoutRunningIt", [] { std::array needed{"libc.so.6"}; TempFile f{"elf", elf_with_needed(needed)}; auto id = bf::identify(f.path); - EXPECT_EQ(id.format, bf::Format::Elf); - EXPECT_EQ(id.arch, "x86_64"); - EXPECT_TRUE(id.is64); -} + expect(eq(id.format, bf::Format::Elf)); + expect(eq(id.arch, "x86_64")); + expect(bool(id.is64)); +}}; -TEST(PackBinfmt, ReadsElfDtNeededThroughTheSegmentTable) { +UTest reads_elf_dt_needed_through_the_segment_table{"PackBinfmt/ReadsElfDtNeededThroughTheSegmentTable", [] { // DT_STRTAB is a virtual address, so the parser has to translate it via // PT_LOAD. Two entries, in file order. std::array needed{"libstdc++.so.6", "libcustom.so.1"}; TempFile f{"elfneeded", elf_with_needed(needed)}; auto names = bf::needed_names(f.path); - ASSERT_TRUE(names.has_value()) << names.error(); - ASSERT_EQ(names->size(), 2u); - EXPECT_EQ((*names)[0], "libstdc++.so.6"); - EXPECT_EQ((*names)[1], "libcustom.so.1"); -} + if (auto&& failed = expect(fatal(names.has_value())); !failed) failed << names.error(); + expect(fatal(eq(names->size(), 2u))); + expect(eq((*names)[0], "libstdc++.so.6")); + expect(eq((*names)[1], "libcustom.so.1")); +}}; -TEST(PackBinfmt, AnElfWithNoDynamicSectionHasZeroDepsAndIsNotAnError) { +UTest an_elf_with_no_dynamic_section_has_zero_deps_and_is_not_an_error{"PackBinfmt/AnElfWithNoDynamicSectionHasZeroDepsAndIsNotAnError", [] { // A fully static binary. "No dependencies" and "could not be read" must // not look alike — one is a packageable artifact and the other is a bug. auto bytes = elf_with_needed({}); @@ -380,52 +382,52 @@ TEST(PackBinfmt, AnElfWithNoDynamicSectionHasZeroDepsAndIsNotAnError) { put(bytes, 120, 0, 4); TempFile f{"elfstatic", bytes}; auto names = bf::needed_names(f.path); - ASSERT_TRUE(names.has_value()) << names.error(); - EXPECT_TRUE(names->empty()); -} + if (auto&& failed = expect(fatal(names.has_value())); !failed) failed << names.error(); + expect(names->empty()); +}}; // SPLIT INTO THREE ON PURPOSE. The combined version died with SIGSEGV on the // macOS ARM64 runner and nowhere else, and a single test that builds a // fixture, identifies it and parses it cannot say WHICH of the three it was. // A test that cannot localise its own failure is a test that costs a CI round // per hypothesis. -TEST(PackBinfmt, ThePeFixtureItselfIsWellFormed) { +UTest the_pe_fixture_itself_is_well_formed{"PackBinfmt/ThePeFixtureItselfIsWellFormed", [] { auto bytes = pe_with_imports(pe_imports(), pe_delayed()); - ASSERT_GT(bytes.size(), 0x400u); - EXPECT_EQ(bytes.substr(0, 2), "MZ"); - EXPECT_EQ(bytes.substr(0x40, 4), std::string("PE\0\0", 4)); + expect(fatal(gt(bytes.size(), 0x400u))); + expect(eq(bytes.substr(0, 2), "MZ")); + expect(eq(bytes.substr(0x40, 4), std::string("PE\0\0", 4))); // The names have to be IN the image, or every assertion below is about // the fixture rather than about the reader. for (auto want : {"KERNEL32.dll", "vcruntime140.dll", "dbghelp.dll"}) - EXPECT_NE(bytes.find(want), std::string::npos) << want; -} + expect(ne(bytes.find(want), std::string::npos)) << want; +}}; -TEST(PackBinfmt, IdentifiesPe) { +UTest identifies_pe{"PackBinfmt/IdentifiesPe", [] { TempFile f{"peid", pe_with_imports(pe_imports(), pe_delayed())}; auto id = bf::identify(f.path); - EXPECT_EQ(id.format, bf::Format::Pe); - EXPECT_EQ(id.arch, "x86_64"); - EXPECT_TRUE(id.is64); -} + expect(eq(id.format, bf::Format::Pe)); + expect(eq(id.arch, "x86_64")); + expect(bool(id.is64)); +}}; -TEST(PackBinfmt, ReadsBothPeImportDirectories) { +UTest reads_both_pe_import_directories{"PackBinfmt/ReadsBothPeImportDirectories", [] { TempFile f{"peimp", pe_with_imports(pe_imports(), pe_delayed())}; auto names = bf::needed_names(f.path); - ASSERT_TRUE(names.has_value()) << names.error(); + if (auto&& failed = expect(fatal(names.has_value())); !failed) failed << names.error(); // The DELAY-loaded one is a dependency too, and leaving it out is worse // than leaving out an ordinary import: a missing delay-load does not fail // at startup, it fails at the first call through it. - EXPECT_EQ(names->size(), 3u); + expect(eq(names->size(), 3u)); for (auto want : {"KERNEL32.dll", "vcruntime140.dll", "dbghelp.dll"}) { bool found = false; for (auto const& n : *names) found = found || n == want; - EXPECT_TRUE(found) << want << " missing from the closure"; + expect(bool(found)) << want << " missing from the closure"; } -} +}}; -TEST(PackBinfmt, ADosStubWithoutAPeSignatureIsNotAPe) { +UTest a_dos_stub_without_a_pe_signature_is_not_a_pe{"PackBinfmt/ADosStubWithoutAPeSignatureIsNotAPe", [] { // "MZ" alone is a DOS executable. Calling it PE would send the caller // into a parser that cannot succeed, and the error would describe the // wrong thing. @@ -433,11 +435,11 @@ TEST(PackBinfmt, ADosStubWithoutAPeSignatureIsNotAPe) { b[0] = 'M'; b[1] = 'Z'; put(b, 0x3C, 0x40, 4); // points at zeros, not "PE\0\0" TempFile f{"dos", b}; - EXPECT_EQ(bf::identify(f.path).format, bf::Format::Unknown); - EXPECT_FALSE(bf::needed_names(f.path).has_value()); -} + expect(eq(bf::identify(f.path).format, bf::Format::Unknown)); + expect(!(bf::needed_names(f.path).has_value())); +}}; -TEST(PackBinfmt, AGarbageELfanewIsRejectedAndDoesNotThrow) { +UTest a_garbage_e_lfanew_is_rejected_and_does_not_throw{"PackBinfmt/AGarbageELfanewIsRejectedAndDoesNotThrow", [] { // An "MZ" file whose `e_lfanew` points past the end is ordinary malformed // input: a truncated download, a DOS stub, a text file named `.exe`. // @@ -450,15 +452,15 @@ TEST(PackBinfmt, AGarbageELfanewIsRejectedAndDoesNotThrow) { b[0] = 'M'; b[1] = 'Z'; put(b, 0x3C, lfanew, 4); TempFile f{"mzjunk", b}; - EXPECT_NO_THROW({ - EXPECT_EQ(bf::identify(f.path).format, bf::Format::Unknown) + expect(nothrow([&] { { + expect(eq(bf::identify(f.path).format, bf::Format::Unknown)) << "e_lfanew=" << lfanew; - EXPECT_FALSE(bf::needed_names(f.path).has_value()); - }) << "e_lfanew=" << lfanew; + expect(!(bf::needed_names(f.path).has_value())); + }; })) << "e_lfanew=" << lfanew; } -} +}}; -TEST(PackBinfmt, TruncatedInputIsRejectedRatherThanRead) { +UTest truncated_input_is_rejected_rather_than_read{"PackBinfmt/TruncatedInputIsRejectedRatherThanRead", [] { // Malformed input is ordinary: a half-downloaded file, a text file named // `.exe`. Every read is bounds-checked, so the parser is total over it. for (std::size_t keep : {0u, 4u, 0x40u, 0x80u}) { @@ -466,45 +468,45 @@ TEST(PackBinfmt, TruncatedInputIsRejectedRatherThanRead) { bytes.resize(keep); TempFile f{"trunc", bytes}; auto names = bf::needed_names(f.path); - if (names) EXPECT_TRUE(names->empty()) << "keep=" << keep; + if (names) expect(names->empty()) << "keep=" << keep; } -} +}}; -TEST(PackBinfmt, TheSystemPredicateKnowsWindowsFromTheToolset) { +UTest the_system_predicate_knows_windows_from_the_toolset{"PackBinfmt/TheSystemPredicateKnowsWindowsFromTheToolset", [] { // Windows' own: shipping a private copy is a broken program, not a // heavier one. for (auto n : {"KERNEL32.dll", "kernel32.dll", "ucrtbase.dll", "ntdll.dll", "api-ms-win-crt-runtime-l1-1-0.dll"}) - EXPECT_TRUE(bf::is_system_lib(bf::Format::Pe, n)) << n; + expect(bf::is_system_lib(bf::Format::Pe, n)) << n; // NOT Windows' own. These belong to the TOOLSET, and whether they travel // is `cxx_runtime`'s decision — the one call this predicate must not make // on its behalf. for (auto n : {"vcruntime140.dll", "vcruntime140_1.dll", "msvcp140.dll", "libwinpthread-1.dll", "libstdc++-6.dll"}) - EXPECT_FALSE(bf::is_system_lib(bf::Format::Pe, n)) << n; + expect(!bf::is_system_lib(bf::Format::Pe, n)) << n; // ELF keeps the manylinux allow-list it always had. - EXPECT_TRUE(bf::is_system_lib(bf::Format::Elf, "libstdc++.so.6")); - EXPECT_TRUE(bf::is_system_lib(bf::Format::Elf, "ld-linux-x86-64.so.2")); - EXPECT_FALSE(bf::is_system_lib(bf::Format::Elf, "libcurl.so.4")); -} + expect(bf::is_system_lib(bf::Format::Elf, "libstdc++.so.6")); + expect(bf::is_system_lib(bf::Format::Elf, "ld-linux-x86-64.so.2")); + expect(!bf::is_system_lib(bf::Format::Elf, "libcurl.so.4")); +}}; // ─── Mach-O: #630 §4 — the reader `needed_names` used to refuse ───────── -TEST(PackBinfmt, IdentifiesThinAndFatMachOWithoutRunningIt) { +UTest identifies_thin_and_fat_mach_o_without_running_it{"PackBinfmt/IdentifiesThinAndFatMachOWithoutRunningIt", [] { TempFile thin{"macho-thin", macho_thin(/*is64=*/true, /*bigEndian=*/false, kCpuTypeArm64, {{kLcLoadDylib, kDylibStructSize, "/usr/lib/libSystem.B.dylib"}})}; - EXPECT_EQ(bf::identify(thin.path).format, bf::Format::MachO); + expect(eq(bf::identify(thin.path).format, bf::Format::MachO)); TempFile fat{"macho-fat", macho_fat({{kCpuTypeX86_64, macho_thin(true, false, kCpuTypeX86_64, {})}, {kCpuTypeArm64, macho_thin(true, false, kCpuTypeArm64, {})}})}; - EXPECT_EQ(bf::identify(fat.path).format, bf::Format::MachO); -} + expect(eq(bf::identify(fat.path).format, bf::Format::MachO)); +}}; -TEST(PackBinfmt, ThinMachOReadsNamesAndRpathsInLoadCommandOrder) { +UTest thin_mach_o_reads_names_and_rpaths_in_load_command_order{"PackBinfmt/ThinMachOReadsNamesAndRpathsInLoadCommandOrder", [] { // Two LC_LOAD_DYLIB, one LC_LOAD_WEAK_DYLIB, two LC_RPATH — the record's // §4.3 fixture shape, in one file. TempFile f{"macho-thin-arm64", @@ -517,20 +519,20 @@ TEST(PackBinfmt, ThinMachOReadsNamesAndRpathsInLoadCommandOrder) { })}; auto r = bf::macho_needed(f.path, "aarch64"); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_EQ(r->names, (std::vector{ - "@rpath/libfoo.dylib", "/usr/lib/libSystem.B.dylib", "@rpath/libbar.dylib"})); - EXPECT_EQ(r->rpaths, (std::vector{ - "@executable_path/../Frameworks", "@loader_path/../lib"})); + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(eq(r->names, (std::vector{ + "@rpath/libfoo.dylib", "/usr/lib/libSystem.B.dylib", "@rpath/libbar.dylib"}))); + expect(eq(r->rpaths, (std::vector{ + "@executable_path/../Frameworks", "@loader_path/../lib"}))); // `needed_names` completes for Mach-O now: names alone, through the same // dispatcher ELF and PE already go through. auto names = bf::needed_names(f.path); - ASSERT_TRUE(names.has_value()) << names.error(); - EXPECT_EQ(*names, r->names); -} + if (auto&& failed = expect(fatal(names.has_value())); !failed) failed << names.error(); + expect(eq(*names, r->names)); +}}; -TEST(PackBinfmt, AWrongArchRequestOnAThinFileStillReadsIt) { +UTest a_wrong_arch_request_on_a_thin_file_still_reads_it{"PackBinfmt/AWrongArchRequestOnAThinFileStillReadsIt", [] { // There is only one slice in a thin file; refusing to read it because // the caller asked for a different architecture would be wrong — the // caller already knows what it built, and mcpp never builds a thin @@ -539,11 +541,11 @@ TEST(PackBinfmt, AWrongArchRequestOnAThinFileStillReadsIt) { macho_thin(true, false, kCpuTypeArm64, {{kLcLoadDylib, kDylibStructSize, "@rpath/libfoo.dylib"}})}; auto r = bf::macho_needed(f.path, "x86_64"); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_EQ(r->names, (std::vector{"@rpath/libfoo.dylib"})); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(eq(r->names, (std::vector{"@rpath/libfoo.dylib"}))); +}}; -TEST(PackBinfmt, FatMachOReadsOnlyTheRequestedSlice) { +UTest fat_mach_o_reads_only_the_requested_slice{"PackBinfmt/FatMachOReadsOnlyTheRequestedSlice", [] { auto x64 = macho_thin(true, false, kCpuTypeX86_64, {{kLcLoadDylib, kDylibStructSize, "onlyintel.dylib"}}); auto arm = macho_thin(true, false, kCpuTypeArm64, @@ -552,19 +554,18 @@ TEST(PackBinfmt, FatMachOReadsOnlyTheRequestedSlice) { macho_fat({{kCpuTypeX86_64, x64}, {kCpuTypeArm64, arm}})}; auto arm64Read = bf::macho_needed(fat.path, "aarch64"); - ASSERT_TRUE(arm64Read.has_value()) << arm64Read.error(); - EXPECT_EQ(arm64Read->names, (std::vector{"onlyarm.dylib"})); + if (auto&& failed = expect(fatal(arm64Read.has_value())); !failed) failed << arm64Read.error(); + expect(eq(arm64Read->names, (std::vector{"onlyarm.dylib"}))); // A name present only in the OTHER slice must be absent — the whole // point of selecting by architecture rather than reading both. - EXPECT_EQ(std::ranges::find(arm64Read->names, "onlyintel.dylib"), - arm64Read->names.end()); + expect(eq(std::ranges::find(arm64Read->names, "onlyintel.dylib"), arm64Read->names.end())); auto x64Read = bf::macho_needed(fat.path, "x86_64"); - ASSERT_TRUE(x64Read.has_value()) << x64Read.error(); - EXPECT_EQ(x64Read->names, (std::vector{"onlyintel.dylib"})); -} + if (auto&& failed = expect(fatal(x64Read.has_value())); !failed) failed << x64Read.error(); + expect(eq(x64Read->names, (std::vector{"onlyintel.dylib"}))); +}}; -TEST(PackBinfmt, ABigEndianMagicThinMachOIsReadCorrectly) { +UTest a_big_endian_magic_thin_mach_o_is_read_correctly{"PackBinfmt/ABigEndianMagicThinMachOIsReadCorrectly", [] { // The CIGAM leg: every integer in the header and its load commands is // written big-endian, which `macho_thin_needed` has to detect from the // magic alone and undo. @@ -573,20 +574,20 @@ TEST(PackBinfmt, ABigEndianMagicThinMachOIsReadCorrectly) { {{kLcLoadDylib, kDylibStructSize, "/usr/lib/libSystem.B.dylib"}, {kLcRpath, kRpathStructSize, "@executable_path/../lib"}})}; auto r = bf::macho_needed(f.path, {}); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_EQ(r->names, (std::vector{"/usr/lib/libSystem.B.dylib"})); - EXPECT_EQ(r->rpaths, (std::vector{"@executable_path/../lib"})); -} - -TEST(PackBinfmt, TheSystemPredicateKnowsMachOsOwnRoots) { - EXPECT_TRUE(bf::is_system_lib(bf::Format::MachO, "/usr/lib/libSystem.B.dylib")); - EXPECT_TRUE(bf::is_system_lib(bf::Format::MachO, + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(eq(r->names, (std::vector{"/usr/lib/libSystem.B.dylib"}))); + expect(eq(r->rpaths, (std::vector{"@executable_path/../lib"}))); +}}; + +UTest the_system_predicate_knows_mach_os_own_roots{"PackBinfmt/TheSystemPredicateKnowsMachOsOwnRoots", [] { + expect(bf::is_system_lib(bf::Format::MachO, "/usr/lib/libSystem.B.dylib")); + expect(bf::is_system_lib(bf::Format::MachO, "/System/Library/Frameworks/Foundation.framework/Foundation")); // Not yet resolved to a file -- `@rpath` names a search, not a root. - EXPECT_FALSE(bf::is_system_lib(bf::Format::MachO, "@rpath/libc++.1.dylib")); -} + expect(!bf::is_system_lib(bf::Format::MachO, "@rpath/libc++.1.dylib")); +}}; -TEST(PackBinfmt, ResolveMachoNamesTriesRpathsInOrderAndReportsUnresolved) { +UTest resolve_macho_names_tries_rpaths_in_order_and_reports_unresolved{"PackBinfmt/ResolveMachoNamesTriesRpathsInOrderAndReportsUnresolved", [] { auto dir = std::filesystem::temp_directory_path() / std::format("mcpp-macho-resolve-{}", std::chrono::steady_clock::now().time_since_epoch().count()); @@ -613,28 +614,28 @@ TEST(PackBinfmt, ResolveMachoNamesTriesRpathsInOrderAndReportsUnresolved) { }; auto resolved = bf::resolve_macho_names(names, rpaths, bin, bin); - ASSERT_EQ(resolved.size(), 2u); + expect(fatal(eq(resolved.size(), 2u))); - EXPECT_EQ(resolved[0].name, "@rpath/dup.dylib"); - EXPECT_FALSE(resolved[0].unresolved); + expect(eq(resolved[0].name, "@rpath/dup.dylib")); + expect(!(resolved[0].unresolved)); // `lexically_normal`: the resolver joins `@executable_path/../first` onto // `bin` without collapsing the `..` itself (that is the filesystem's job, // at `exists()`), so the raw and the hand-built path differ textually // while naming the same file. - EXPECT_EQ(resolved[0].path.lexically_normal(), (first / "dup.dylib").lexically_normal()) + expect(eq(resolved[0].path.lexically_normal(), (first / "dup.dylib").lexically_normal())) << "the FIRST rpath entry must win when both would resolve"; // Reported, not skipped: the vector still carries an entry for the name // that resolved nowhere. - EXPECT_EQ(resolved[1].name, "@rpath/nowhere.dylib"); - EXPECT_TRUE(resolved[1].unresolved); - EXPECT_TRUE(resolved[1].path.empty()); + expect(eq(resolved[1].name, "@rpath/nowhere.dylib")); + expect(bool(resolved[1].unresolved)); + expect(resolved[1].path.empty()); std::error_code ec; std::filesystem::remove_all(dir, ec); -} +}}; -TEST(PackBinfmt, ResolveMachoNamesSubstitutesExecutableAndLoaderPath) { +UTest resolve_macho_names_substitutes_executable_and_loader_path{"PackBinfmt/ResolveMachoNamesSubstitutesExecutableAndLoaderPath", [] { auto dir = std::filesystem::temp_directory_path() / std::format("mcpp-macho-resolve2-{}", std::chrono::steady_clock::now().time_since_epoch().count()); @@ -650,28 +651,28 @@ TEST(PackBinfmt, ResolveMachoNamesSubstitutesExecutableAndLoaderPath) { "@loader_path/libplugin.dylib", }; auto resolved = bf::resolve_macho_names(names, {}, exeDir, loaderDir); - ASSERT_EQ(resolved.size(), 2u); - EXPECT_EQ(resolved[0].path, exeDir / "libbeside.dylib"); - EXPECT_FALSE(resolved[0].unresolved); - EXPECT_EQ(resolved[1].path, loaderDir / "libplugin.dylib"); - EXPECT_FALSE(resolved[1].unresolved); + expect(fatal(eq(resolved.size(), 2u))); + expect(eq(resolved[0].path, exeDir / "libbeside.dylib")); + expect(!(resolved[0].unresolved)); + expect(eq(resolved[1].path, loaderDir / "libplugin.dylib")); + expect(!(resolved[1].unresolved)); std::error_code ec; std::filesystem::remove_all(dir, ec); -} +}}; // ─── the zip writer ────────────────────────────────────────────────────── -TEST(PackZip, Crc32MatchesTheKnownVectors) { +UTest crc32_matches_the_known_vectors{"PackZip/Crc32MatchesTheKnownVectors", [] { // Self-consistency proves nothing here: an archive whose CRCs are wrong // extracts with a warning on some tools and silently on others. - EXPECT_EQ(mcpp::pack::zip::crc32(""), 0u); - EXPECT_EQ(mcpp::pack::zip::crc32("123456789"), 0xCBF43926u); - EXPECT_EQ(mcpp::pack::zip::crc32( - "The quick brown fox jumps over the lazy dog"), 0x414FA339u); -} + expect(eq(mcpp::pack::zip::crc32(""), 0u)); + expect(eq(mcpp::pack::zip::crc32("123456789"), 0xCBF43926u)); + expect(eq(mcpp::pack::zip::crc32( + "The quick brown fox jumps over the lazy dog"), 0x414FA339u)); +}}; -TEST(PackZip, WritesAReadableArchiveWithForwardSlashNames) { +UTest writes_a_readable_archive_with_forward_slash_names{"PackZip/WritesAReadableArchiveWithForwardSlashNames", [] { auto dir = std::filesystem::temp_directory_path() / std::format("mcpp-zip-{}", std::chrono::steady_clock::now() .time_since_epoch().count()); @@ -685,29 +686,29 @@ TEST(PackZip, WritesAReadableArchiveWithForwardSlashNames) { }; auto out = dir / "pkg.zip"; auto r = mcpp::pack::zip::write(out, entries); - ASSERT_TRUE(r.has_value()) << r.error(); + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); std::ifstream is(out, std::ios::binary); std::string bytes((std::istreambuf_iterator(is)), {}); - ASSERT_GE(bytes.size(), 22u); - EXPECT_EQ(bytes.substr(0, 4), std::string("PK\x03\x04", 4)) + expect(fatal(ge(bytes.size(), 22u))); + expect(eq(bytes.substr(0, 4), std::string("PK\x03\x04", 4))) << "no local file header signature"; - EXPECT_NE(bytes.find(std::string("PK\x01\x02", 4)), std::string::npos) + expect(ne(bytes.find(std::string("PK\x01\x02", 4)), std::string::npos)) << "no central directory"; - EXPECT_EQ(bytes.substr(bytes.size() - 22, 4), std::string("PK\x05\x06", 4)) + expect(eq(bytes.substr(bytes.size() - 22, 4), std::string("PK\x05\x06", 4))) << "end-of-central-directory is not last (a trailing comment?)"; // Names are stored verbatim; a backslash here is what makes an archive // written on Windows extract to one oddly-named file everywhere else. - EXPECT_NE(bytes.find("pkg/a.txt"), std::string::npos); - EXPECT_EQ(bytes.find("pkg\\a.txt"), std::string::npos); + expect(ne(bytes.find("pkg/a.txt"), std::string::npos)); + expect(eq(bytes.find("pkg\\a.txt"), std::string::npos)); // Stored, not deflated: the payload appears literally. - EXPECT_NE(bytes.find("hello"), std::string::npos); + expect(ne(bytes.find("hello"), std::string::npos)); std::error_code ec; std::filesystem::remove_all(dir, ec); -} +}}; -TEST(PackZip, TwoWritesOfTheSameInputAreByteIdentical) { +UTest two_writes_of_the_same_input_are_byte_identical{"PackZip/TwoWritesOfTheSameInputAreByteIdentical", [] { // A published checksum only means something if the archive is a function // of its contents. Reading mtimes would make it a function of when. auto dir = std::filesystem::temp_directory_path() @@ -721,10 +722,10 @@ TEST(PackZip, TwoWritesOfTheSameInputAreByteIdentical) { std::ifstream is(p, std::ios::binary); return std::string((std::istreambuf_iterator(is)), {}); }; - ASSERT_TRUE(mcpp::pack::zip::write(dir / "one.zip", entries).has_value()); - ASSERT_TRUE(mcpp::pack::zip::write(dir / "two.zip", entries).has_value()); - EXPECT_EQ(read(dir / "one.zip"), read(dir / "two.zip")); + expect(fatal(mcpp::pack::zip::write(dir / "one.zip", entries).has_value())); + expect(fatal(mcpp::pack::zip::write(dir / "two.zip", entries).has_value())); + expect(eq(read(dir / "one.zip"), read(dir / "two.zip"))); std::error_code ec; std::filesystem::remove_all(dir, ec); -} +}}; diff --git a/tests/unit/test_pack_closure.cpp b/tests/unit/test_pack_closure.cpp index aafd6cb67..3fdf2f2bb 100644 --- a/tests/unit/test_pack_closure.cpp +++ b/tests/unit/test_pack_closure.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pack; +using namespace mcpplibs::mtest; + // #634 A3: the closure `mcpp pack` reads from the files for the PE, Android and // Mach-O rows. The rules are asserted here, over hand-built objects in a // temporary tree, because two of the three rows cannot be produced on the @@ -203,7 +205,7 @@ using Names = std::vector; // ── Android ────────────────────────────────────────────────────────────── -TEST(PackClosureAndroid, TheStubDirectoryIsTheDevicesAndEveryOtherNameTravels) { +UTest the_stub_directory_is_the_devices_and_every_other_name_travels{"PackClosureAndroid/TheStubDirectoryIsTheDevicesAndEveryOtherNameTravels", [] { Tree t; t.write("platform/libc.so", "stub"); t.write("platform/libm.so", "stub"); @@ -221,14 +223,14 @@ TEST(PackClosureAndroid, TheStubDirectoryIsTheDevicesAndEveryOtherNameTravels) { in.platformDirs = {t.root / "platform"}; auto r = mcpp::pack::read_closure(in); - EXPECT_EQ(member_dests(r), (Names{"libc++_shared.so", "libfw.so"})); - EXPECT_EQ(r.platform, (Names{"libc.so", "libm.so"})); - EXPECT_TRUE(r.unresolved.empty()); - ASSERT_EQ(r.members.size(), 2u); - EXPECT_EQ(r.members[0].source, t.root / "ndk" / "libc++_shared.so"); -} + expect(eq(member_dests(r), (Names{"libc++_shared.so", "libfw.so"}))); + expect(eq(r.platform, (Names{"libc.so", "libm.so"}))); + expect(r.unresolved.empty()); + expect(fatal(eq(r.members.size(), 2u))); + expect(eq(r.members[0].source, t.root / "ndk" / "libc++_shared.so")); +}}; -TEST(PackClosureAndroid, ANameFoundNowhereLeavesTheClosureIncomplete) { +UTest a_name_found_nowhere_leaves_the_closure_incomplete{"PackClosureAndroid/ANameFoundNowhereLeavesTheClosureIncomplete", [] { Tree t; t.write("platform/libc.so", "stub"); auto app = t.write("build/libapp.so", elf_needing({"libgone.so", "libc.so"})); @@ -240,16 +242,15 @@ TEST(PackClosureAndroid, ANameFoundNowhereLeavesTheClosureIncomplete) { in.platformDirs = {t.root / "platform"}; auto r = mcpp::pack::read_closure(in); - EXPECT_TRUE(r.members.empty()); - EXPECT_EQ(unresolved_names(r), (Names{"libgone.so"})); - ASSERT_EQ(r.unresolved.size(), 1u); - EXPECT_NE(r.unresolved[0].why.find((t.root / "build").string()), std::string::npos) - << "the reason names where it looked: " << r.unresolved[0].why; -} + expect(r.members.empty()); + expect(eq(unresolved_names(r), (Names{"libgone.so"}))); + expect(fatal(eq(r.unresolved.size(), 1u))); + if (auto&& failed = expect(ne(r.unresolved[0].why.find((t.root / "build").string()), std::string::npos)); !failed) failed << "the reason names where it looked: " << r.unresolved[0].why; +}}; // ── PE, unchanged by the generalisation ────────────────────────────────── -TEST(PackClosurePe, SystemNamesAndNamesFoundNowhereAreTheTargets) { +UTest system_names_and_names_found_nowhere_are_the_targets{"PackClosurePe/SystemNamesAndNamesFoundNowhereAreTheTargets", [] { Tree t; auto exe = t.write("bin/app.exe", pe_importing({"KERNEL32.dll", "foo.dll", "missing.dll"})); @@ -262,12 +263,12 @@ TEST(PackClosurePe, SystemNamesAndNamesFoundNowhereAreTheTargets) { in.searchDirs = {t.root / "bin", t.root / "deps"}; auto r = mcpp::pack::read_closure(in); - EXPECT_EQ(member_dests(r), (Names{"bar.dll", "foo.dll"})); - EXPECT_EQ(r.platform, (Names{"KERNEL32.dll", "missing.dll"})); - EXPECT_TRUE(r.unresolved.empty()) << "PE's rule resolves every name"; -} + expect(eq(member_dests(r), (Names{"bar.dll", "foo.dll"}))); + expect(eq(r.platform, (Names{"KERNEL32.dll", "missing.dll"}))); + expect(r.unresolved.empty()) << "PE's rule resolves every name"; +}}; -TEST(PackClosurePe, ForceBundleReachesTheSystemList) { +UTest force_bundle_reaches_the_system_list{"PackClosurePe/ForceBundleReachesTheSystemList", [] { Tree t; auto exe = t.write("bin/app.exe", pe_importing({"dbghelp.dll"})); t.write("deps/dbghelp.dll", pe_importing({})); @@ -277,13 +278,13 @@ TEST(PackClosurePe, ForceBundleReachesTheSystemList) { in.rule = mcpp::pack::ClosureRule::Pe; in.searchDirs = {t.root / "deps"}; auto without = mcpp::pack::read_closure(in); - EXPECT_EQ(without.platform, (Names{"dbghelp.dll"})); + expect(eq(without.platform, (Names{"dbghelp.dll"}))); in.forceBundle = {"dbghelp.dll"}; auto with = mcpp::pack::read_closure(in); - EXPECT_EQ(member_dests(with), (Names{"dbghelp.dll"})); - EXPECT_TRUE(with.platform.empty()); -} + expect(eq(member_dests(with), (Names{"dbghelp.dll"}))); + expect(with.platform.empty()); +}}; // ── SPEC-007 R4.2/R4.3: one destination, one writer (#723 self-review) ── // @@ -302,7 +303,7 @@ std::string read_bytes(const std::filesystem::path& p) { } // namespace -TEST(PlaceRuntimeDlls, ANameTheDeployListPlacesIsNeitherOverwrittenNorPlaced) { +UTest a_name_the_deploy_list_places_is_neither_overwritten_nor_placed{"PlaceRuntimeDlls/ANameTheDeployListPlacesIsNeitherOverwrittenNorPlaced", [] { Tree t; auto exe = t.write("bin/app.exe", pe_importing({"foo.dll"})); // The deploy list's own file, already beside the program (as it would be @@ -313,16 +314,16 @@ TEST(PlaceRuntimeDlls, ANameTheDeployListPlacesIsNeitherOverwrittenNorPlaced) { auto r = mcpp::pack::place_runtime_dlls( exe, {t.root / "deps"}, /*placedBefore=*/{}, /*placedByDeploy=*/{"foo.dll"}); - ASSERT_TRUE(r.has_value()) << r.error().message; - EXPECT_TRUE(r->names.empty()) << "the deploy list's name was placed a second time"; - EXPECT_TRUE(r->sources.empty()); - ASSERT_EQ(r->warnings.size(), 1u); - EXPECT_NE(r->warnings[0].find("foo.dll"), std::string::npos) << r->warnings[0]; + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error().message; + expect(r->names.empty()) << "the deploy list's name was placed a second time"; + expect(r->sources.empty()); + expect(fatal(eq(r->warnings.size(), 1u))); + if (auto&& failed = expect(ne(r->warnings[0].find("foo.dll"), std::string::npos)); !failed) failed << r->warnings[0]; // Not overwritten: the file beside the program is still the deploy list's. - EXPECT_EQ(read_bytes(t.root / "bin" / "foo.dll"), pe_importing({}) + "DEPLOYED"); -} + expect(eq(read_bytes(t.root / "bin" / "foo.dll"), pe_importing({}) + "DEPLOYED")); +}}; -TEST(PlaceRuntimeDlls, ANameTheDeployListPlacesWithMatchingBytesIsSilent) { +UTest a_name_the_deploy_list_places_with_matching_bytes_is_silent{"PlaceRuntimeDlls/ANameTheDeployListPlacesWithMatchingBytesIsSilent", [] { Tree t; auto exe = t.write("bin/app.exe", pe_importing({"foo.dll"})); const auto bytes = pe_importing({}) + "SAME"; @@ -331,12 +332,12 @@ TEST(PlaceRuntimeDlls, ANameTheDeployListPlacesWithMatchingBytesIsSilent) { auto r = mcpp::pack::place_runtime_dlls( exe, {t.root / "deps"}, {}, {"foo.dll"}); - ASSERT_TRUE(r.has_value()) << r.error().message; - EXPECT_TRUE(r->names.empty()); - EXPECT_TRUE(r->warnings.empty()); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error().message; + expect(r->names.empty()); + expect(r->warnings.empty()); +}}; -TEST(PlaceRuntimeDlls, TheDeployListNameIsMatchedCaseInsensitively) { +UTest the_deploy_list_name_is_matched_case_insensitively{"PlaceRuntimeDlls/TheDeployListNameIsMatchedCaseInsensitively", [] { Tree t; auto exe = t.write("bin/app.exe", pe_importing({"Foo.DLL"})); // The deploy list itself may spell the name differently (it named a @@ -347,27 +348,27 @@ TEST(PlaceRuntimeDlls, TheDeployListNameIsMatchedCaseInsensitively) { auto r = mcpp::pack::place_runtime_dlls( exe, {t.root / "deps"}, {}, {"foo.dll"}); - ASSERT_TRUE(r.has_value()) << r.error().message; - EXPECT_TRUE(r->names.empty()) + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error().message; + expect(r->names.empty()) << "PE names are case-insensitive; the deploy list's entry should still match"; -} +}}; -TEST(PlaceRuntimeDlls, ANameNotInTheDeployListIsPlacedAsBefore) { +UTest a_name_not_in_the_deploy_list_is_placed_as_before{"PlaceRuntimeDlls/ANameNotInTheDeployListIsPlacedAsBefore", [] { Tree t; auto exe = t.write("bin/app.exe", pe_importing({"foo.dll"})); t.write("deps/foo.dll", pe_importing({}) + "PAYLOAD"); auto r = mcpp::pack::place_runtime_dlls(exe, {t.root / "deps"}); - ASSERT_TRUE(r.has_value()) << r.error().message; - EXPECT_EQ(r->names, (Names{"foo.dll"})); - EXPECT_EQ(r->sources, (std::vector{t.root / "deps" / "foo.dll"})); - EXPECT_TRUE(r->warnings.empty()); - EXPECT_EQ(read_bytes(t.root / "bin" / "foo.dll"), pe_importing({}) + "PAYLOAD"); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error().message; + expect(eq(r->names, (Names{"foo.dll"}))); + expect(eq(r->sources, (std::vector{t.root / "deps" / "foo.dll"}))); + expect(r->warnings.empty()); + expect(eq(read_bytes(t.root / "bin" / "foo.dll"), pe_importing({}) + "PAYLOAD")); +}}; // ── Mach-O ─────────────────────────────────────────────────────────────── -TEST(PackClosureMachO, AnRpathDylibBesideTheProgramIsAMemberTransitively) { +UTest an_rpath_dylib_beside_the_program_is_a_member_transitively{"PackClosureMachO/AnRpathDylibBesideTheProgramIsAMemberTransitively", [] { Tree t; auto app = t.write("bin/app", macho_naming({"@rpath/libfw.dylib", "/usr/lib/libSystem.B.dylib"}, @@ -383,12 +384,12 @@ TEST(PackClosureMachO, AnRpathDylibBesideTheProgramIsAMemberTransitively) { in.arch = "aarch64"; auto r = mcpp::pack::read_closure(in); - EXPECT_EQ(member_dests(r), (Names{"libdep.dylib", "libfw.dylib"})); - EXPECT_EQ(r.platform, (Names{"/usr/lib/libSystem.B.dylib", "/usr/lib/libc++.1.dylib"})); - EXPECT_TRUE(r.unresolved.empty()); -} + expect(eq(member_dests(r), (Names{"libdep.dylib", "libfw.dylib"}))); + expect(eq(r.platform, (Names{"/usr/lib/libSystem.B.dylib", "/usr/lib/libc++.1.dylib"}))); + expect(r.unresolved.empty()); +}}; -TEST(PackClosureMachO, ATrailingSlashAndExecutablePathQualifyAsTheProgramsDirectory) { +UTest a_trailing_slash_and_executable_path_qualify_as_the_programs_directory{"PackClosureMachO/ATrailingSlashAndExecutablePathQualifyAsTheProgramsDirectory", [] { Tree t; auto app = t.write("bin/app", macho_naming({"@rpath/liba.dylib", "@executable_path/libb.dylib"}, @@ -400,11 +401,11 @@ TEST(PackClosureMachO, ATrailingSlashAndExecutablePathQualifyAsTheProgramsDirect in.object = app; in.rule = mcpp::pack::ClosureRule::MachO; auto r = mcpp::pack::read_closure(in); - EXPECT_EQ(member_dests(r), (Names{"liba.dylib", "libb.dylib"})); - EXPECT_TRUE(r.unresolved.empty()); -} + expect(eq(member_dests(r), (Names{"liba.dylib", "libb.dylib"}))); + expect(r.unresolved.empty()); +}}; -TEST(PackClosureMachO, AnAbsoluteInstallNameOutsideTheOsRootsIsUnresolved) { +UTest an_absolute_install_name_outside_the_os_roots_is_unresolved{"PackClosureMachO/AnAbsoluteInstallNameOutsideTheOsRootsIsUnresolved", [] { Tree t; // The file exists on this machine; the loader on another one reads that // path, not the tree, so a copy would not be what loads. An install name @@ -420,14 +421,13 @@ TEST(PackClosureMachO, AnAbsoluteInstallNameOutsideTheOsRootsIsUnresolved) { in.object = app; in.rule = mcpp::pack::ClosureRule::MachO; auto r = mcpp::pack::read_closure(in); - EXPECT_TRUE(r.members.empty()); - EXPECT_EQ(unresolved_names(r), (Names{installName})); - ASSERT_EQ(r.unresolved.size(), 1u); - EXPECT_NE(r.unresolved[0].why.find("absolute install name"), std::string::npos) - << r.unresolved[0].why; -} + expect(r.members.empty()); + expect(eq(unresolved_names(r), (Names{installName}))); + expect(fatal(eq(r.unresolved.size(), 1u))); + if (auto&& failed = expect(ne(r.unresolved[0].why.find("absolute install name"), std::string::npos)); !failed) failed << r.unresolved[0].why; +}}; -TEST(PackClosureMachO, AnRpathOnlyAnAbsoluteEntryReachesIsUnresolved) { +UTest an_rpath_only_an_absolute_entry_reaches_is_unresolved{"PackClosureMachO/AnRpathOnlyAnAbsoluteEntryReachesIsUnresolved", [] { Tree t; t.write("store/libfw.dylib", macho_naming({}, {})); auto app = t.write("bin/app", @@ -437,11 +437,10 @@ TEST(PackClosureMachO, AnRpathOnlyAnAbsoluteEntryReachesIsUnresolved) { in.object = app; in.rule = mcpp::pack::ClosureRule::MachO; auto r = mcpp::pack::read_closure(in); - EXPECT_TRUE(r.members.empty()); - EXPECT_EQ(unresolved_names(r), (Names{"@rpath/libfw.dylib"})); - ASSERT_EQ(r.unresolved.size(), 1u); - EXPECT_NE(r.unresolved[0].why.find("@loader_path"), std::string::npos) - << r.unresolved[0].why; + expect(r.members.empty()); + expect(eq(unresolved_names(r), (Names{"@rpath/libfw.dylib"}))); + expect(fatal(eq(r.unresolved.size(), 1u))); + if (auto&& failed = expect(ne(r.unresolved[0].why.find("@loader_path"), std::string::npos)); !failed) failed << r.unresolved[0].why; // The negative direction: the same dylib staged through a qualifying // rpath is a member, sourced from wherever the loader finds it here. @@ -450,12 +449,12 @@ TEST(PackClosureMachO, AnRpathOnlyAnAbsoluteEntryReachesIsUnresolved) { {(t.root / "store").string(), "@loader_path"})); in.object = app2; auto r2 = mcpp::pack::read_closure(in); - EXPECT_EQ(member_dests(r2), (Names{"libfw.dylib"})); - ASSERT_EQ(r2.members.size(), 1u); - EXPECT_EQ(r2.members[0].source, t.root / "store" / "libfw.dylib"); -} + expect(eq(member_dests(r2), (Names{"libfw.dylib"}))); + expect(fatal(eq(r2.members.size(), 1u))); + expect(eq(r2.members[0].source, t.root / "store" / "libfw.dylib")); +}}; -TEST(PackClosureMachO, ALoaderPathSubdirectoryIsStagedAtThatSubdirectory) { +UTest a_loader_path_subdirectory_is_staged_at_that_subdirectory{"PackClosureMachO/ALoaderPathSubdirectoryIsStagedAtThatSubdirectory", [] { Tree t; auto app = t.write("bin/app", macho_naming({"@loader_path/plugins/libp.dylib"}, {})); t.write("bin/plugins/libp.dylib", macho_naming({"@loader_path/libq.dylib"}, {})); @@ -465,11 +464,11 @@ TEST(PackClosureMachO, ALoaderPathSubdirectoryIsStagedAtThatSubdirectory) { in.object = app; in.rule = mcpp::pack::ClosureRule::MachO; auto r = mcpp::pack::read_closure(in); - EXPECT_EQ(member_dests(r), (Names{"plugins/libp.dylib", "plugins/libq.dylib"})); - EXPECT_TRUE(r.unresolved.empty()); -} + expect(eq(member_dests(r), (Names{"plugins/libp.dylib", "plugins/libq.dylib"}))); + expect(r.unresolved.empty()); +}}; -TEST(PackClosureMachO, ANameOutsideTheProgramsDirectoryIsUnresolved) { +UTest a_name_outside_the_programs_directory_is_unresolved{"PackClosureMachO/ANameOutsideTheProgramsDirectoryIsUnresolved", [] { Tree t; t.write("Frameworks/libz.dylib", macho_naming({}, {})); auto app = t.write("bin/app", @@ -479,11 +478,11 @@ TEST(PackClosureMachO, ANameOutsideTheProgramsDirectoryIsUnresolved) { in.object = app; in.rule = mcpp::pack::ClosureRule::MachO; auto r = mcpp::pack::read_closure(in); - EXPECT_TRUE(r.members.empty()); - EXPECT_EQ(unresolved_names(r), (Names{"@executable_path/../Frameworks/libz.dylib"})); -} + expect(r.members.empty()); + expect(eq(unresolved_names(r), (Names{"@executable_path/../Frameworks/libz.dylib"}))); +}}; -TEST(PackClosureMachO, AMissingDylibIsUnresolvedAndNamed) { +UTest a_missing_dylib_is_unresolved_and_named{"PackClosureMachO/AMissingDylibIsUnresolvedAndNamed", [] { Tree t; auto app = t.write("bin/app", macho_naming({"@rpath/libgone.dylib"}, {"@loader_path"})); @@ -491,6 +490,6 @@ TEST(PackClosureMachO, AMissingDylibIsUnresolvedAndNamed) { in.object = app; in.rule = mcpp::pack::ClosureRule::MachO; auto r = mcpp::pack::read_closure(in); - EXPECT_TRUE(r.members.empty()); - EXPECT_EQ(unresolved_names(r), (Names{"@rpath/libgone.dylib"})); -} + expect(r.members.empty()); + expect(eq(unresolved_names(r), (Names{"@rpath/libgone.dylib"}))); +}}; diff --git a/tests/unit/test_pack_distribution.cpp b/tests/unit/test_pack_distribution.cpp index 7f2112f74..7857e5060 100644 --- a/tests/unit/test_pack_distribution.cpp +++ b/tests/unit/test_pack_distribution.cpp @@ -2,13 +2,15 @@ // deflated and aligned members, SHA-256, the tar mcpp writes, what `unpack` // refuses to create, the launchers, the package metadata, `--merge` and the // single file's payload. -#include import std; +import mcpplibs.mtest; import mcpp.pack.compress; import mcpp.pack.distribution; import mcpp.pack.zip; +using namespace mcpplibs::mtest; + namespace fs = std::filesystem; namespace c = mcpp::pack::compress; namespace d = mcpp::pack::distribution; @@ -45,113 +47,112 @@ std::uint32_t le32(std::string_view b, std::size_t at) { // A zip of `entries` at `dir/name`, members stored. fs::path zip_of(const fs::path& dir, std::string_view name, const std::vector& entries) { const auto out = dir / name; - EXPECT_TRUE(mcpp::pack::zip::write(out, entries)); + expect(bool(mcpp::pack::zip::write(out, entries))); return out; } } // namespace -TEST(PackCompress, ParsesAlgorithmsAndLevels) { +UTest parses_algorithms_and_levels{"PackCompress/ParsesAlgorithmsAndLevels", [] { auto z = c::parse("zstd:19"); - ASSERT_TRUE(z); - EXPECT_EQ(z->algorithm, c::Algorithm::Zstd); - EXPECT_EQ(z->level, 19); - EXPECT_TRUE(z->stated); - EXPECT_TRUE(c::parse("none")); - EXPECT_FALSE(c::parse("none:3")); - EXPECT_FALSE(c::parse("gzip:10")); - EXPECT_FALSE(c::parse("brotli")); - EXPECT_EQ(c::tar_extension(c::Algorithm::Xz), ".tar.xz"); - EXPECT_EQ(c::tar_extension(c::Algorithm::None), ".tar"); -} + expect(fatal(bool(z))); + expect(eq(z->algorithm, c::Algorithm::Zstd)); + expect(eq(z->level, 19)); + expect(bool(z->stated)); + expect(bool(c::parse("none"))); + expect(!c::parse("none:3")); + expect(!c::parse("gzip:10")); + expect(!c::parse("brotli")); + expect(eq(c::tar_extension(c::Algorithm::Xz), ".tar.xz")); + expect(eq(c::tar_extension(c::Algorithm::None), ".tar")); +}}; // An unstated level is -1, the codec's default; every stated one is honoured, // `xz:0` and `zstd:1` too. -TEST(PackCompress, AStatedLevelIsHonoured) { - EXPECT_EQ(c::Choice{}.level, -1); - EXPECT_EQ(c::parse("xz")->level, -1); - EXPECT_EQ(c::parse("xz:0")->level, 0); - EXPECT_EQ(c::parse("zstd:1")->level, 1); +UTest a_stated_level_is_honoured{"PackCompress/AStatedLevelIsHonoured", [] { + expect(eq(c::Choice{}.level, -1)); + expect(eq(c::parse("xz")->level, -1)); + expect(eq(c::parse("xz:0")->level, 0)); + expect(eq(c::parse("zstd:1")->level, 1)); std::string text; for (int i = 0; i < 4000; ++i) text += std::format("{} ", i * 7919 % 10007); auto fastest = c::bytes(text, *c::parse("xz:0")); auto preset = c::bytes(text, *c::parse("xz")); - ASSERT_TRUE(fastest && preset); - EXPECT_NE(*fastest, *preset) << "xz:0 was written at the default preset"; + expect(fatal(fastest && preset)); + expect(ne(*fastest, *preset)) << "xz:0 was written at the default preset"; auto z1 = c::bytes(text, *c::parse("zstd:1")); auto z19 = c::bytes(text, *c::parse("zstd")); - ASSERT_TRUE(z1 && z19); - EXPECT_NE(*z1, *z19) << "zstd:1 was written at the default level"; -} + expect(fatal(z1 && z19)); + expect(ne(*z1, *z19)) << "zstd:1 was written at the default level"; +}}; // A gzip member names no file, no time and the system 255, whatever host // wrote it. -TEST(PackCompress, AGzipHeaderIsTheSameOnEveryHost) { +UTest a_gzip_header_is_the_same_on_every_host{"PackCompress/AGzipHeaderIsTheSameOnEveryHost", [] { auto gz = c::bytes("the same bytes", c::Choice{}); - ASSERT_TRUE(gz); - ASSERT_GE(gz->size(), 10u); - EXPECT_EQ(static_cast((*gz)[0]), 0x1f); - EXPECT_EQ(static_cast((*gz)[3]), 0) << "a name, a comment or an extra field"; - EXPECT_EQ(le32(*gz, 4), 0u) << "a modification time"; - EXPECT_EQ(static_cast((*gz)[9]), 255) << "the system byte"; -} - -TEST(PackCompress, EveryCodecRoundTrips) { + expect(fatal(bool(gz))); + expect(fatal(ge(gz->size(), 10u))); + expect(eq(static_cast((*gz)[0]), 0x1f)); + expect(eq(static_cast((*gz)[3]), 0)) << "a name, a comment or an extra field"; + expect(eq(le32(*gz, 4), 0u)) << "a modification time"; + expect(eq(static_cast((*gz)[9]), 255)) << "the system byte"; +}}; + +UTest every_codec_round_trips{"PackCompress/EveryCodecRoundTrips", [] { std::string text; for (int i = 0; i < 4000; ++i) text += std::format("line {} of a package\n", i % 37); for (auto a : { c::Algorithm::Gzip, c::Algorithm::Zstd, c::Algorithm::Xz, c::Algorithm::None }) { c::Choice ch; ch.algorithm = a; auto packed = c::bytes(text, ch); - ASSERT_TRUE(packed) << c::name(a); - if (a != c::Algorithm::None) EXPECT_LT(packed->size(), text.size()) << c::name(a); + expect(fatal(bool(packed))) << c::name(a); + if (a != c::Algorithm::None) expect(lt(packed->size(), text.size())) << c::name(a); auto back = c::decompress(*packed, a); - ASSERT_TRUE(back) << c::name(a); - EXPECT_EQ(*back, text) << c::name(a); + expect(fatal(bool(back))) << c::name(a); + expect(eq(*back, text)) << c::name(a); } auto raw = c::deflate_raw(text, -1); - ASSERT_TRUE(raw); + expect(fatal(bool(raw))); auto back = c::inflate_raw(*raw, text.size()); - ASSERT_TRUE(back); - EXPECT_EQ(*back, text); -} + expect(fatal(bool(back))); + expect(eq(*back, text)); +}}; // The name of a tar archive states its compression, the inverse of // tar_extension; a test archive is written and read by it. -TEST(PackCompress, ATarNameStatesItsCompression) { +UTest a_tar_name_states_its_compression{"PackCompress/ATarNameStatesItsCompression", [] { for (auto a : { c::Algorithm::Gzip, c::Algorithm::Zstd, c::Algorithm::Xz, c::Algorithm::None }) - EXPECT_EQ(c::tar_algorithm(std::format("t{}", c::tar_extension(a))), a) << c::name(a); - EXPECT_EQ(c::tar_algorithm("t.tgz"), c::Algorithm::Gzip); - EXPECT_FALSE(c::tar_algorithm("t.zip")); - EXPECT_FALSE(c::tar_algorithm("t.gz")); - EXPECT_FALSE(c::tar_algorithm("tests")); -} + expect(eq(c::tar_algorithm(std::format("t{}", c::tar_extension(a))), a)) << c::name(a); + expect(eq(c::tar_algorithm("t.tgz"), c::Algorithm::Gzip)); + expect(!c::tar_algorithm("t.zip")); + expect(!c::tar_algorithm("t.gz")); + expect(!c::tar_algorithm("tests")); +}}; // zlib counts in 32 bits; the input is handed over in pieces, so one gzip // member holds more than one piece (1 GiB) and comes back whole. A member cut // short is refused, not waited on. -TEST(PackCompress, AGzipMemberLargerThanOnePieceRoundTrips) { +UTest a_gzip_member_larger_than_one_piece_round_trips{"PackCompress/AGzipMemberLargerThanOnePieceRoundTrips", [] { std::string big((std::size_t{1} << 30) + 4096, '\0'); big.replace(big.size() - 7, 7, "the end"); auto packed = c::bytes(big, *c::parse("gzip:1")); - ASSERT_TRUE(packed) << packed.error(); + if (auto&& failed = expect(fatal(bool(packed))); !failed) failed << packed.error(); auto back = c::decompress(*packed, c::Algorithm::Gzip); - ASSERT_TRUE(back) << back.error(); - EXPECT_EQ(back->size(), big.size()); - EXPECT_TRUE(back->ends_with("the end")); + if (auto&& failed = expect(fatal(bool(back))); !failed) failed << back.error(); + expect(eq(back->size(), big.size())); + expect(back->ends_with("the end")); big = {}; back = {}; - EXPECT_FALSE(c::decompress(std::string_view(*packed).substr(0, packed->size() / 2), c::Algorithm::Gzip)); -} + expect(!(c::decompress(std::string_view(*packed).substr(0, packed->size() / 2), c::Algorithm::Gzip))); +}}; -TEST(PackDistribution, Sha256OfKnownVectors) { - EXPECT_EQ(d::sha256(""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); - EXPECT_EQ(d::sha256("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); - EXPECT_EQ(d::sha256(std::string(1000, 'a')), - "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3"); -} +UTest sha256_of_known_vectors{"PackDistribution/Sha256OfKnownVectors", [] { + expect(eq(d::sha256(""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")); + expect(eq(d::sha256("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")); + expect(eq(d::sha256(std::string(1000, 'a')), "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3")); +}}; -TEST(PackZip, AnAlignedMemberIsStoredOnItsBoundary) { +UTest an_aligned_member_is_stored_on_its_boundary{"PackZip/AnAlignedMemberIsStoredOnItsBoundary", [] { Tmp t; write(t.path / "a.oke", std::string(3000, 'x')); write(t.path / "libb.okso", std::string(70000, 'y')); @@ -161,16 +162,16 @@ TEST(PackZip, AnAlignedMemberIsStoredOnItsBoundary) { }; mcpp::pack::zip::WriteOptions o; o.align = 65536; - ASSERT_TRUE(mcpp::pack::zip::write(t.path / "out.zip", entries, o)); + expect(fatal(bool(mcpp::pack::zip::write(t.path / "out.zip", entries, o)))); std::ifstream in(t.path / "out.zip", std::ios::binary); std::string b{std::istreambuf_iterator(in), {}}; // Each member's data, found through the central directory, starts on a // 64 KiB boundary and is stored. std::size_t eocd = b.rfind(std::string("PK\x05\x06", 4)); - ASSERT_NE(eocd, std::string::npos); + expect(fatal(ne(eocd, std::string::npos))); std::size_t at = le32(b, eocd + 16); for (int n = 0; n < 2; ++n) { - ASSERT_EQ(le32(b, at), 0x02014b50u); + expect(fatal(eq(le32(b, at), 0x02014b50u))); const auto method = static_cast(b[at + 10]); const auto nameLen = static_cast(static_cast(b[at + 28])) | static_cast(static_cast(b[at + 29])) << 8; @@ -179,106 +180,105 @@ TEST(PackZip, AnAlignedMemberIsStoredOnItsBoundary) { | static_cast(static_cast(b[local + 27])) << 8; const auto lextra = static_cast(static_cast(b[local + 28])) | static_cast(static_cast(b[local + 29])) << 8; - EXPECT_EQ(method, 0); - EXPECT_EQ((local + 30 + lname + lextra) % 65536, 0u); + expect(eq(method, 0)); + expect(eq((local + 30 + lname + lextra) % 65536, 0u)); at += 46 + nameLen; } -} +}}; -TEST(PackZip, DeflateWhenItShrinks) { +UTest deflate_when_it_shrinks{"PackZip/DeflateWhenItShrinks", [] { Tmp t; write(t.path / "text", std::string(10000, 'z')); mcpp::pack::zip::WriteOptions o; o.deflate = true; - ASSERT_TRUE(mcpp::pack::zip::write(t.path / "out.zip", { { "text", t.path / "text", false, {} } }, o)); - EXPECT_LT(fs::file_size(t.path / "out.zip"), 1000u); + expect(fatal(bool(mcpp::pack::zip::write(t.path / "out.zip", { { "text", t.path / "text", false, {} } }, o)))); + expect(lt(fs::file_size(t.path / "out.zip"), 1000u)); // And it reads back. auto root = d::unpack(t.path / "out.zip", t.path / "x"); - ASSERT_FALSE(root) << "a zip without one top-level directory is not a package"; -} + expect(fatal(!root)) << "a zip without one top-level directory is not a package"; +}}; -TEST(PackDistribution, LaunchersPickTheArchitectureAndTheRunner) { +UTest launchers_pick_the_architecture_and_the_runner{"PackDistribution/LaunchersPickTheArchitectureAndTheRunner", [] { d::Launch l; l.name = "hello"; l.runner = { "okrun", "{}" }; l.entry = { { "x86_64", "hello.oke" }, { "aarch64", "hello.oke" } }; l.loader = "okrun"; const auto sh = d::posix_launcher(l); - EXPECT_TRUE(sh.starts_with("#!/bin/sh\n")); - EXPECT_NE(sh.find("x86_64|amd64|AMD64) arch=x86_64"), std::string::npos); - EXPECT_NE(sh.find("'aarch64') entry='hello.oke' ;;"), std::string::npos); - EXPECT_NE(sh.find("image=\"$top/lib/$arch/$entry\""), std::string::npos); - EXPECT_NE(sh.find("runner=\"$top/libexec/$loader/$os-$arch/$loader\""), std::string::npos); - EXPECT_NE(sh.find("exec \"$runner\" \"$image\" \"$@\""), std::string::npos); + expect(sh.starts_with("#!/bin/sh\n")); + expect(ne(sh.find("x86_64|amd64|AMD64) arch=x86_64"), std::string::npos)); + expect(ne(sh.find("'aarch64') entry='hello.oke' ;;"), std::string::npos)); + expect(ne(sh.find("image=\"$top/lib/$arch/$entry\""), std::string::npos)); + expect(ne(sh.find("runner=\"$top/libexec/$loader/$os-$arch/$loader\""), std::string::npos)); + expect(ne(sh.find("exec \"$runner\" \"$image\" \"$@\""), std::string::npos)); // The package is found through a link to the launcher, without readlink -f. - EXPECT_NE(sh.find("readlink -- \"$self\""), std::string::npos); - EXPECT_EQ(sh.find("readlink -f"), std::string::npos); + expect(ne(sh.find("readlink -- \"$self\""), std::string::npos)); + expect(eq(sh.find("readlink -f"), std::string::npos)); // A POSIX shell on Windows hands over to the program of the same name. - EXPECT_NE(sh.find("MINGW*|MSYS*|CYGWIN*) exec \"$top/bin/\"'hello'.exe \"$@\""), std::string::npos) << sh; + expect(ne(sh.find("MINGW*|MSYS*|CYGWIN*) exec \"$top/bin/\"'hello'.exe \"$@\""), std::string::npos)) << sh; // On macOS a runner carrying the quarantine mark of a browser's download is // named, with the command that removes the mark (mcpp design 2026-10-30 // part 2 §10 D6), before it is started. const auto mark = sh.find("xattr -p com.apple.quarantine \"$runner\""); - EXPECT_NE(mark, std::string::npos) << sh; - EXPECT_NE(sh.find("xattr -dr com.apple.quarantine"), std::string::npos) << sh; - EXPECT_LT(mark, sh.find("exec \"$runner\"")) << sh; -} + expect(ne(mark, std::string::npos)) << sh; + expect(ne(sh.find("xattr -dr com.apple.quarantine"), std::string::npos)) << sh; + expect(lt(mark, sh.find("exec \"$runner\""))) << sh; +}}; // The runner's words expand as `mcpp run` expands them: a word's `{}` is the // image inside the word as well as alone; with no `{}` the image is appended. -TEST(PackDistribution, LaunchersExpandTheRunnerAsMcppRunDoes) { +UTest launchers_expand_the_runner_as_mcpp_run_does{"PackDistribution/LaunchersExpandTheRunnerAsMcppRunDoes", [] { d::Launch l; l.name = "hello"; l.entry = { { "x86_64", "hello.oke" } }; l.runner = { "okrun", "--image={}", "it's" }; - EXPECT_NE(d::posix_launcher(l).find("exec \"$runner\" '--image='\"$image\" 'it'\\''s' \"$@\""), - std::string::npos) << d::posix_launcher(l); + expect(ne(d::posix_launcher(l).find("exec \"$runner\" '--image='\"$image\" 'it'\\''s' \"$@\""), std::string::npos)) << d::posix_launcher(l); l.runner = { "okrun", "--quiet" }; - EXPECT_NE(d::posix_launcher(l).find("exec \"$runner\" '--quiet' \"$image\" \"$@\""), std::string::npos) + expect(ne(d::posix_launcher(l).find("exec \"$runner\" '--quiet' \"$image\" \"$@\""), std::string::npos)) << d::posix_launcher(l); -} +}}; // The Windows launcher is a program (design 2026-10-29 §4.3): its source states // the launch as constants, finds the package from its own path, reads the // machine's architecture, and expands the runner's words as `mcpp run` does. // Any word is stated, quotes and percent signs included: a raw string holds it. -TEST(PackDistribution, TheWindowsLauncherIsAProgram) { +UTest the_windows_launcher_is_a_program{"PackDistribution/TheWindowsLauncherIsAProgram", [] { d::Launch l; l.name = "hello"; l.entry = { { "x86_64", "hello.oke" }, { "aarch64", "hello.oke" } }; l.runner = { "okrun", "--say=\"50%\"", "{}" }; l.loader = "okrun"; const auto src = d::exe_launcher_main(l); - EXPECT_NE(src.find("import mcpplibs.os;"), std::string::npos) << src; - EXPECT_NE(src.find("os::env::image()"), std::string::npos) << src; - EXPECT_NE(src.find("PROCESSOR_ARCHITEW6432"), std::string::npos) << src; - EXPECT_NE(src.find("R\"mcpp(--say=\"50%\")mcpp\""), std::string::npos) << src; + expect(ne(src.find("import mcpplibs.os;"), std::string::npos)) << src; + expect(ne(src.find("os::env::image()"), std::string::npos)) << src; + expect(ne(src.find("PROCESSOR_ARCHITEW6432"), std::string::npos)) << src; + expect(ne(src.find("R\"mcpp(--say=\"50%\")mcpp\""), std::string::npos)) << src; // One program for every set of legs: the entry's name, the architectures // the directories under `lib/`. - EXPECT_NE(src.find("kEntry = R\"mcpp(hello.oke)mcpp\""), std::string::npos) << src; - EXPECT_EQ(src.find("R\"mcpp(aarch64)mcpp\""), std::string::npos) << src; - EXPECT_NE(src.find("fs::directory_iterator(top / \"lib\""), std::string::npos) << src; - EXPECT_NE(src.find("(\"windows-\" + arch)"), std::string::npos) << src; - EXPECT_NE(src.find("os::process::exec"), std::string::npos) << src; + expect(ne(src.find("kEntry = R\"mcpp(hello.oke)mcpp\""), std::string::npos)) << src; + expect(eq(src.find("R\"mcpp(aarch64)mcpp\""), std::string::npos)) << src; + expect(ne(src.find("fs::directory_iterator(top / \"lib\""), std::string::npos)) << src; + expect(ne(src.find("(\"windows-\" + arch)"), std::string::npos)) << src; + expect(ne(src.find("os::process::exec"), std::string::npos)) << src; const auto manifest = d::exe_launcher_manifest("hello", "1.0.0"); - EXPECT_NE(manifest.find("mcpplibs.os = \"0.4.0\""), std::string::npos) << manifest; - EXPECT_NE(manifest.find("[targets.hello]"), std::string::npos) << manifest; -} + expect(ne(manifest.find("mcpplibs.os = \"0.4.0\""), std::string::npos)) << manifest; + expect(ne(manifest.find("[targets.hello]"), std::string::npos)) << manifest; +}}; // The POSIX launcher, run through a link to it from another directory, finds // its package and starts the runner with the image spliced into its word. -TEST(PackDistribution, APosixLauncherRunsThroughALink) { - if (!fs::exists("/bin/sh")) GTEST_SKIP() << "no /bin/sh"; +UTest a_posix_launcher_runs_through_a_link{"PackDistribution/APosixLauncherRunsThroughALink", [] { + if (!fs::exists("/bin/sh")) skip("no /bin/sh"); std::string arch; { Tmp probe; if (std::system(std::format("uname -m > '{}'", (probe.path / "m").string()).c_str()) != 0) - GTEST_SKIP() << "no uname"; + skip("no uname"); arch = slurp(probe.path / "m"); } arch = arch.starts_with("x86_64") || arch.starts_with("amd64") ? "x86_64" : arch.starts_with("aarch64") || arch.starts_with("arm64") ? "aarch64" : ""; - if (arch.empty()) GTEST_SKIP() << "no launcher row for this machine"; + if (arch.empty()) skip("no launcher row for this machine"); Tmp t; const auto pkg = t.path / "my pkg"; d::Launch l; @@ -296,16 +296,16 @@ TEST(PackDistribution, APosixLauncherRunsThroughALink) { const auto rc = std::system(std::format("PATH='{}':\"$PATH\" '{}' 'a b' c > '{}' 2>&1", (t.path / "path").string(), (t.path / "elsewhere/hello").string(), out.string()).c_str()); - EXPECT_EQ(rc, 0) << slurp(out); + expect(eq(rc, 0)) << slurp(out); // The physical path: the launcher follows links to the directories too // (macOS's temporary directory is under a link, /var -> /private/var). - EXPECT_EQ(slurp(out), std::format("--image={}|a b|c|", - (fs::canonical(pkg) / "lib" / arch / "hello.oke").string())); -} + expect(eq(slurp(out), std::format("--image={}|a b|c|", + (fs::canonical(pkg) / "lib" / arch / "hello.oke").string()))); +}}; // A shared object is `.so` or `.so.`; a name that merely holds `.so` // is not one. -TEST(PackDistribution, ALibraryIsNamedSoOrSoVersion) { +UTest a_library_is_named_so_or_so_version{"PackDistribution/ALibraryIsNamedSoOrSoVersion", [] { Tmp t; const auto root = t.path / "app-1.0"; for (auto f : { "lib/libz.so", "lib/libz.so.1.3", "share/notes.sol", "share/a.so.x", "share/b.socket" }) @@ -314,30 +314,30 @@ TEST(PackDistribution, ALibraryIsNamedSoOrSoVersion) { p.name = "app"; p.version = "1.0"; auto described = d::describe(root, p, {}); - ASSERT_TRUE(described) << described.error(); + if (auto&& failed = expect(fatal(bool(described))); !failed) failed << described.error(); std::map roles; for (auto const& f : described->files) roles[f.path] = f.role; - EXPECT_EQ(roles["lib/libz.so"], "library"); - EXPECT_EQ(roles["lib/libz.so.1.3"], "library"); - EXPECT_EQ(roles["share/notes.sol"], "resource"); - EXPECT_EQ(roles["share/a.so.x"], "resource"); - EXPECT_EQ(roles["share/b.socket"], "resource"); -} + expect(eq(roles["lib/libz.so"], "library")); + expect(eq(roles["lib/libz.so.1.3"], "library")); + expect(eq(roles["share/notes.sol"], "resource")); + expect(eq(roles["share/a.so.x"], "resource")); + expect(eq(roles["share/b.socket"], "resource")); +}}; // `read` refuses a document that names a path outside the package. -TEST(PackDistribution, ReadRefusesAPathOutsideThePackage) { +UTest read_refuses_a_path_outside_the_package{"PackDistribution/ReadRefusesAPathOutsideThePackage", [] { Tmp t; for (auto bad : { "../outside", "/etc/passwd", "a/../../b", "./bin/x", "bin\\\\x", "" }) { write(t.path / "mcpp-package.json", std::format(R"({{"schema": 1, "name": "x", "version": "1", "files": [{{"path": "{}", "role": "resource", "sha256": ""}}]}})", bad)); auto r = d::read(t.path); - ASSERT_FALSE(r) << bad; - EXPECT_NE(r.error().find("not a path inside the package"), std::string::npos) << r.error(); + expect(fatal(!r)) << bad; + if (auto&& failed = expect(ne(r.error().find("not a path inside the package"), std::string::npos)); !failed) failed << r.error(); } -} +}}; -TEST(PackDistribution, PackageMetadataDescribesEveryFile) { +UTest package_metadata_describes_every_file{"PackDistribution/PackageMetadataDescribesEveryFile", [] { Tmp t; const auto root = t.path / "app-1.0-openkal"; write(root / "bin/app", "#!/bin/sh\n"); @@ -353,31 +353,31 @@ TEST(PackDistribution, PackageMetadataDescribesEveryFile) { d::Stated s; s.role["bin/app"] = "launcher"; auto described = d::describe(root, p, s); - ASSERT_TRUE(described); + expect(fatal(bool(described))); auto back = d::read(root); - ASSERT_TRUE(back) << back.error(); + if (auto&& failed = expect(fatal(bool(back))); !failed) failed << back.error(); std::map roles; for (auto const& f : back->files) roles[f.path] = f.role; - EXPECT_EQ(roles["bin/app"], "launcher"); - EXPECT_EQ(roles["lib/x86_64/app.oke"], "image"); - EXPECT_EQ(roles["share/data.txt"], "resource"); - EXPECT_EQ(roles["LICENSE"], "metadata"); - EXPECT_EQ(back->entry.at("x86_64"), "lib/x86_64/app.oke"); + expect(eq(roles["bin/app"], "launcher")); + expect(eq(roles["lib/x86_64/app.oke"], "image")); + expect(eq(roles["share/data.txt"], "resource")); + expect(eq(roles["LICENSE"], "metadata")); + expect(eq(back->entry.at("x86_64"), "lib/x86_64/app.oke")); for (auto const& f : back->files) - EXPECT_EQ(f.sha256, d::sha256_file(root / f.path)) << f.path; -} + expect(eq(f.sha256, d::sha256_file(root / f.path))) << f.path; +}}; -TEST(PackDistribution, TreePayloadIsOrderedRecords) { +UTest tree_payload_is_ordered_records{"PackDistribution/TreePayloadIsOrderedRecords", [] { Tmp t; write(t.path / "b/x", "12"); write(t.path / "a", "3"); auto payload = d::tree_payload(t.path); - ASSERT_TRUE(payload); - EXPECT_TRUE(payload->starts_with("MCPPTREE1\n")); + expect(fatal(bool(payload))); + expect(payload->starts_with("MCPPTREE1\n")); // `a` before `b` before `b/x`. const auto a = payload->find("a"), b = payload->find("b/x"); - EXPECT_LT(a, b); -} + expect(lt(a, b)); +}}; // ─── the tar mcpp writes ────────────────────────────────────────────────── @@ -403,69 +403,68 @@ const c::Choice kPlain{ c::Algorithm::None, -1, false }; // Two writes of one tree are the same bytes, its times and modes changed in // between: no owner, no time, the modes normalised. -TEST(PackTar, TheSameTreeIsTheSameBytes) { +UTest the_same_tree_is_the_same_bytes{"PackTar/TheSameTreeIsTheSameBytes", [] { Tmp t; const auto root = sample_tree(t.path); for (auto ch : { kPlain, c::Choice{} }) { - ASSERT_TRUE(d::write_tar(root, t.path / "one", ch)); + expect(fatal(bool(d::write_tar(root, t.path / "one", ch)))); fs::last_write_time(root / "share/data.txt", fs::file_time_type::clock::now() - std::chrono::hours(48)); fs::permissions(root / "share/data.txt", fs::perms::group_write, fs::perm_options::add); - ASSERT_TRUE(d::write_tar(root, t.path / "two", ch)); - EXPECT_EQ(slurp(t.path / "one"), slurp(t.path / "two")) << c::name(ch.algorithm); + expect(fatal(bool(d::write_tar(root, t.path / "two", ch)))); + expect(eq(slurp(t.path / "one"), slurp(t.path / "two"))) << c::name(ch.algorithm); } - ASSERT_TRUE(d::write_tar(root, t.path / "plain.tar", kPlain)); + expect(fatal(bool(d::write_tar(root, t.path / "plain.tar", kPlain)))); const auto tar = slurp(t.path / "plain.tar"); - ASSERT_GE(tar.size(), 1024u); - EXPECT_EQ(tar.substr(0, 9), std::string("app-1.0/\0", 9)); - EXPECT_EQ(tar.substr(108, 8), std::string("0000000\0", 8)) << "uid"; - EXPECT_EQ(tar.substr(116, 8), std::string("0000000\0", 8)) << "gid"; - EXPECT_EQ(tar.substr(136, 12), std::string("00000000000\0", 12)) << "mtime"; - EXPECT_EQ(tar.substr(257, 6), std::string("ustar\0", 6)); - EXPECT_EQ(tar.substr(265, 32), std::string(32, '\0')) << "uname"; - EXPECT_EQ(tar.size() % 512, 0u); -} + expect(fatal(ge(tar.size(), 1024u))); + expect(eq(tar.substr(0, 9), std::string("app-1.0/\0", 9))); + expect(eq(tar.substr(108, 8), std::string("0000000\0", 8))) << "uid"; + expect(eq(tar.substr(116, 8), std::string("0000000\0", 8))) << "gid"; + expect(eq(tar.substr(136, 12), std::string("00000000000\0", 12))) << "mtime"; + expect(eq(tar.substr(257, 6), std::string("ustar\0", 6))); + expect(eq(tar.substr(265, 32), std::string(32, '\0'))) << "uname"; + expect(eq(tar.size() % 512, 0u)); +}}; // What mcpp writes, `unpack` reads back: files, modes, links, long names. -TEST(PackTar, UnpackReadsWhatWriteTarWrote) { +UTest unpack_reads_what_write_tar_wrote{"PackTar/UnpackReadsWhatWriteTarWrote", [] { Tmp t; const auto root = sample_tree(t.path); for (auto ch : { kPlain, c::Choice{}, c::Choice{ c::Algorithm::Zstd, -1, false }, c::Choice{ c::Algorithm::Xz, -1, false } }) { const auto archive = t.path / ("app" + std::string(c::tar_extension(ch.algorithm))); - ASSERT_TRUE(d::write_tar(root, archive, ch)); + expect(fatal(bool(d::write_tar(root, archive, ch)))); const auto into = t.path / ("x-" + std::string(c::name(ch.algorithm))); auto back = d::unpack(archive, into); - ASSERT_TRUE(back) << back.error(); - EXPECT_EQ(back->filename(), "app-1.0"); - EXPECT_EQ(slurp(*back / "share/data.txt"), "data"); - EXPECT_EQ(slurp(*back / "share" / std::string(60, 'd') / (std::string(60, 'e') + ".txt")), "deep"); - EXPECT_NE(fs::status(*back / "bin/app").permissions() & fs::perms::owner_exec, fs::perms::none); - EXPECT_TRUE(fs::is_directory(*back / "empty")); - EXPECT_TRUE(fs::is_symlink(*back / "share/link")); - EXPECT_EQ(fs::read_symlink(*back / "share/far").generic_string(), - std::string(60, 'd') + "/" + std::string(60, 'e') + ".txt"); - EXPECT_EQ(slurp(*back / "share/far"), "deep"); + if (auto&& failed = expect(fatal(bool(back))); !failed) failed << back.error(); + expect(eq(back->filename(), "app-1.0")); + expect(eq(slurp(*back / "share/data.txt"), "data")); + expect(eq(slurp(*back / "share" / std::string(60, 'd') / (std::string(60, 'e') + ".txt")), "deep")); + expect(ne(fs::status(*back / "bin/app").permissions() & fs::perms::owner_exec, fs::perms::none)); + expect(fs::is_directory(*back / "empty")); + expect(fs::is_symlink(*back / "share/link")); + expect(eq(fs::read_symlink(*back / "share/far").generic_string(), std::string(60, 'd') + "/" + std::string(60, 'e') + ".txt")); + expect(eq(slurp(*back / "share/far"), "deep")); } -} +}}; // A tar holding a link out of its tree is refused, and nothing is written. -TEST(PackTar, UnpackRefusesALinkOutOfTheTree) { +UTest unpack_refuses_a_link_out_of_the_tree{"PackTar/UnpackRefusesALinkOutOfTheTree", [] { Tmp t; const auto root = t.path / "app-1.0"; write(root / "a", "a"); fs::create_symlink("../../outside", root / "escape"); - ASSERT_TRUE(d::write_tar(root, t.path / "app.tar", kPlain)); + expect(fatal(bool(d::write_tar(root, t.path / "app.tar", kPlain)))); auto r = d::unpack(t.path / "app.tar", t.path / "x"); - ASSERT_FALSE(r); - EXPECT_NE(r.error().find("outside the package"), std::string::npos) << r.error(); - EXPECT_TRUE(fs::is_empty(t.path / "x")); -} + expect(fatal(!r)); + if (auto&& failed = expect(ne(r.error().find("outside the package"), std::string::npos)); !failed) failed << r.error(); + expect(fs::is_empty(t.path / "x")); +}}; // ─── what unpack refuses ────────────────────────────────────────────────── // A member that would land outside the extraction root, or be written through // a link member, is refused before anything is written. -TEST(PackUnpack, AZipCreatesNothingOutsideItsRoot) { +UTest a_zip_creates_nothing_outside_its_root{"PackUnpack/AZipCreatesNothingOutsideItsRoot", [] { Tmp t; write(t.path / "f", "content"); using E = mcpp::pack::zip::Entry; @@ -487,34 +486,34 @@ TEST(PackUnpack, AZipCreatesNothingOutsideItsRoot) { for (std::size_t i = 0; i < cases.size(); ++i) { const auto into = t.path / std::format("x{}", i); auto r = d::unpack(zip_of(t.path, std::format("c{}.zip", i), cases[i].entries), into); - ASSERT_FALSE(r) << cases[i].label; - EXPECT_NE(r.error().find(cases[i].why), std::string::npos) << cases[i].label << ": " << r.error(); - EXPECT_TRUE(fs::is_empty(into)) << cases[i].label << ": something was written"; + if (auto&& failed = expect(fatal(!r)); !failed) failed << cases[i].label; + if (auto&& failed = expect(ne(r.error().find(cases[i].why), std::string::npos)); !failed) failed << cases[i].label << ": " << r.error(); + if (auto&& failed = expect(fs::is_empty(into)); !failed) failed << cases[i].label << ": something was written"; } - EXPECT_FALSE(fs::exists(t.path / "evil")); + expect(!fs::exists(t.path / "evil")); // A link inside the tree is made, after the files. auto ok = d::unpack(zip_of(t.path, "ok.zip", { { "pkg/l", {}, false, "sub" }, { "pkg/sub/x", f, false, {} } }), t.path / "ok"); - ASSERT_TRUE(ok) << ok.error(); - EXPECT_TRUE(fs::is_symlink(*ok / "l")); - EXPECT_EQ(slurp(*ok / "l/x"), "content"); -} + if (auto&& failed = expect(fatal(bool(ok))); !failed) failed << ok.error(); + expect(fs::is_symlink(*ok / "l")); + expect(eq(slurp(*ok / "l/x"), "content")); +}}; // Every truncation of a zip, and every byte of it changed, is an error or a // tree, never a read past the archive. -TEST(PackUnpack, ATruncatedOrCorruptZipIsAnError) { +UTest a_truncated_or_corrupt_zip_is_an_error{"PackUnpack/ATruncatedOrCorruptZipIsAnError", [] { Tmp t; write(t.path / "f", std::string(300, 'q')); write(t.path / "g", "small"); mcpp::pack::zip::WriteOptions o; o.deflate = true; - ASSERT_TRUE(mcpp::pack::zip::write(t.path / "a.zip", { { "pkg/f", t.path / "f", false, {} }, - { "pkg/g", t.path / "g", true, {} } }, o)); + expect(fatal(bool(mcpp::pack::zip::write(t.path / "a.zip", { { "pkg/f", t.path / "f", false, {} }, + { "pkg/g", t.path / "g", true, {} } }, o)))); const auto whole = slurp(t.path / "a.zip"); for (std::size_t n = 0; n < whole.size(); ++n) { write(t.path / "cut.zip", whole.substr(0, n)); const auto into = t.path / std::format("cut{}", n); - EXPECT_FALSE(d::unpack(t.path / "cut.zip", into)) << n << " bytes"; + expect(!d::unpack(t.path / "cut.zip", into)) << n << " bytes"; fs::remove_all(into); } for (std::size_t n = 0; n < whole.size(); ++n) { @@ -525,21 +524,21 @@ TEST(PackUnpack, ATruncatedOrCorruptZipIsAnError) { (void)d::unpack(t.path / "bad.zip", into); fs::remove_all(into); } -} +}}; // A member whose bytes do not match its CRC-32 is refused. -TEST(PackUnpack, ACrcMismatchIsRefused) { +UTest a_crc_mismatch_is_refused{"PackUnpack/ACrcMismatchIsRefused", [] { Tmp t; write(t.path / "f", "the member's content"); auto zip = slurp(zip_of(t.path, "a.zip", { { "pkg/f", t.path / "f", false, {} } })); const auto at = zip.find("the member's content"); - ASSERT_NE(at, std::string::npos); + expect(fatal(ne(at, std::string::npos))); zip[at] = 'T'; write(t.path / "a.zip", zip); auto r = d::unpack(t.path / "a.zip", t.path / "x"); - ASSERT_FALSE(r); - EXPECT_NE(r.error().find("CRC-32"), std::string::npos) << r.error(); -} + expect(fatal(!r)); + if (auto&& failed = expect(ne(r.error().find("CRC-32"), std::string::npos)); !failed) failed << r.error(); +}}; // ─── --merge ────────────────────────────────────────────────────────────── @@ -556,7 +555,7 @@ fs::path leg_package(const fs::path& dir, std::string_view leg, std::string_view p.legs = { std::string(leg) }; p.runner = { "okrun", "{}" }; p.entry = { { std::string(arch), std::format("lib/{}/app.oke", arch) } }; - EXPECT_TRUE(d::describe(root, p, {})); + expect(bool(d::describe(root, p, {}))); return root; } @@ -564,7 +563,7 @@ fs::path leg_package(const fs::path& dir, std::string_view leg, std::string_view // A file changed after its package was written is refused, named with both // digests. -TEST(PackMerge, AFileThatIsNotItsDigestIsRefused) { +UTest a_file_that_is_not_its_digest_is_refused{"PackMerge/AFileThatIsNotItsDigestIsRefused", [] { Tmp t; const auto a = leg_package(t.path / "a", "x86_64-openkal", "x86_64"); const auto b = leg_package(t.path / "b", "aarch64-openkal", "aarch64"); @@ -573,18 +572,18 @@ TEST(PackMerge, AFileThatIsNotItsDigestIsRefused) { req.inputs = { a, b }; req.output = t.path / "out"; auto r = d::merge(req); - ASSERT_FALSE(r); - EXPECT_NE(r.error().find("lib/aarch64/app.oke"), std::string::npos) << r.error(); - EXPECT_NE(r.error().find(d::sha256("changed")), std::string::npos) << r.error(); - EXPECT_NE(r.error().find(d::sha256("image of aarch64")), std::string::npos) << r.error(); - EXPECT_FALSE(fs::exists(t.path / "out")); -} + expect(fatal(!r)); + if (auto&& failed = expect(ne(r.error().find("lib/aarch64/app.oke"), std::string::npos)); !failed) failed << r.error(); + if (auto&& failed = expect(ne(r.error().find(d::sha256("changed")), std::string::npos)); !failed) failed << r.error(); + if (auto&& failed = expect(ne(r.error().find(d::sha256("image of aarch64")), std::string::npos)); !failed) failed << r.error(); + expect(!fs::exists(t.path / "out")); +}}; // THE LAUNCHERS ARE DERIVED (design 2026-10-29 §4.3): two one-leg trees whose // POSIX launchers differ merge into the tree of both, its launcher written for // both legs; the Windows launcher, one program for every set of legs, is taken // once. A package written before `runs_on` existed is given its pairs. -TEST(PackMerge, TheLaunchersAreWrittenForTheMergedLegs) { +UTest the_launchers_are_written_for_the_merged_legs{"PackMerge/TheLaunchersAreWrittenForTheMergedLegs", [] { Tmp t; auto a = leg_package(t.path / "a", "x86_64-openkal", "x86_64"); auto b = leg_package(t.path / "b", "aarch64-openkal", "aarch64"); @@ -592,34 +591,34 @@ TEST(PackMerge, TheLaunchersAreWrittenForTheMergedLegs) { write(root / "bin/app", std::format("#!/bin/sh\n# the launcher of {}\n", arch)); write(root / "bin/app.exe", std::format("MZ the program of {}", arch)); auto pkg = d::read(root); - ASSERT_TRUE(pkg) << pkg.error(); + if (auto&& failed = expect(fatal(bool(pkg))); !failed) failed << pkg.error(); d::Stated stated; stated.role["bin/app"] = "launcher"; stated.role["bin/app.exe"] = "launcher"; - ASSERT_TRUE(d::describe(root, *pkg, stated)); + expect(fatal(bool(d::describe(root, *pkg, stated)))); } d::MergeRequest req; req.inputs = { a, b }; req.format = "dir"; req.output = t.path / "out"; auto r = d::merge(req); - ASSERT_TRUE(r) << r.error(); + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << r.error(); const auto script = slurp(*r / "bin/app"); - EXPECT_NE(script.find("'x86_64') entry='app.oke'"), std::string::npos) << script; - EXPECT_NE(script.find("'aarch64') entry='app.oke'"), std::string::npos) << script; - EXPECT_EQ(slurp(*r / "bin/app.exe"), "MZ the program of x86_64"); + expect(ne(script.find("'x86_64') entry='app.oke'"), std::string::npos)) << script; + expect(ne(script.find("'aarch64') entry='app.oke'"), std::string::npos)) << script; + expect(eq(slurp(*r / "bin/app.exe"), "MZ the program of x86_64")); auto merged = d::read(*r); - ASSERT_TRUE(merged) << merged.error(); - ASSERT_TRUE(merged->runsOnSet); - ASSERT_TRUE(merged->runsOnWith.contains("okrun")); - EXPECT_NE(std::ranges::find(merged->runsOnWith["okrun"], "macos-aarch64"), merged->runsOnWith["okrun"].end()); + if (auto&& failed = expect(fatal(bool(merged))); !failed) failed << merged.error(); + expect(fatal(bool(merged->runsOnSet))); + expect(fatal(merged->runsOnWith.contains("okrun"))); + expect(ne(std::ranges::find(merged->runsOnWith["okrun"], "macos-aarch64"), merged->runsOnWith["okrun"].end())); for (auto const& f : merged->files) - EXPECT_EQ(d::sha256_file(*r / f.path), f.sha256) << f.path; -} + expect(eq(d::sha256_file(*r / f.path), f.sha256)) << f.path; +}}; // Merged without `-o`, a tree of a platform's legs is named after the platform // (design 2026-10-29 §4.2). -TEST(PackMerge, AMergedTreeIsNamedAfterThePlatform) { +UTest a_merged_tree_is_named_after_the_platform{"PackMerge/AMergedTreeIsNamedAfterThePlatform", [] { Tmp t; std::vector inputs; for (auto [leg, arch] : { std::pair{ "x86_64-openkal", "x86_64" }, std::pair{ "aarch64-openkal", "aarch64" } }) { @@ -632,7 +631,7 @@ TEST(PackMerge, AMergedTreeIsNamedAfterThePlatform) { p.legs = { leg }; p.runner = { "okrun", "{}" }; p.entry = { { arch, std::format("lib/{}/app.oke", arch) } }; - ASSERT_TRUE(d::describe(root, p, {})); + expect(fatal(bool(d::describe(root, p, {})))); inputs.push_back(root); } std::error_code ec; @@ -643,13 +642,13 @@ TEST(PackMerge, AMergedTreeIsNamedAfterThePlatform) { req.format = "dir"; auto r = d::merge(req); fs::current_path(cwd, ec); - ASSERT_TRUE(r) << r.error(); - EXPECT_EQ(r->filename(), "app-1.0-openkal"); -} + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << r.error(); + expect(eq(r->filename(), "app-1.0-openkal")); +}}; // The legs merge into a new directory; an output that exists, or is an input, // is refused, and an input written with a trailing separator is its directory. -TEST(PackMerge, TheOutputIsNewOrAPackage) { +UTest the_output_is_new_or_a_package{"PackMerge/TheOutputIsNewOrAPackage", [] { Tmp t; const auto a = leg_package(t.path / "a", "x86_64-openkal", "x86_64"); const auto b = leg_package(t.path / "b", "aarch64-openkal", "aarch64"); @@ -657,37 +656,37 @@ TEST(PackMerge, TheOutputIsNewOrAPackage) { req.inputs = { fs::path(a.string() + "/"), b }; req.output = t.path / "out"; auto r = d::merge(req); - ASSERT_TRUE(r) << r.error(); + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << r.error(); auto merged = d::read(*r); - ASSERT_TRUE(merged) << merged.error(); - EXPECT_EQ(merged->legs, (std::vector{ "aarch64-openkal", "x86_64-openkal" })); - EXPECT_EQ(slurp(*r / "lib/aarch64/app.oke"), "image of aarch64"); + if (auto&& failed = expect(fatal(bool(merged))); !failed) failed << merged.error(); + expect(eq(merged->legs, (std::vector{ "aarch64-openkal", "x86_64-openkal" }))); + expect(eq(slurp(*r / "lib/aarch64/app.oke"), "image of aarch64")); // Again into the directory it wrote: a directory is never replaced. auto again = d::merge(req); - ASSERT_FALSE(again); - EXPECT_NE(again.error().find("exists"), std::string::npos) << again.error(); + expect(fatal(!again)); + if (auto&& failed = expect(ne(again.error().find("exists"), std::string::npos)); !failed) failed << again.error(); // Onto an input. req.output = a; - ASSERT_FALSE(d::merge(req)); - EXPECT_TRUE(fs::exists(a / "mcpp-package.json")); + expect(fatal(!d::merge(req))); + expect(fs::exists(a / "mcpp-package.json")); // Over a file that is not a package. write(t.path / "notes.tar.gz", "my notes"); req.output = t.path / "notes.tar.gz"; auto over = d::merge(req); - ASSERT_FALSE(over); - EXPECT_NE(over.error().find("not a package"), std::string::npos) << over.error(); - EXPECT_EQ(slurp(t.path / "notes.tar.gz"), "my notes"); + expect(fatal(!over)); + if (auto&& failed = expect(ne(over.error().find("not a package"), std::string::npos)); !failed) failed << over.error(); + expect(eq(slurp(t.path / "notes.tar.gz"), "my notes")); // Over an archive that is one: replaced. req.output = t.path / "app.tar.gz"; - ASSERT_TRUE(d::merge(req)); + expect(fatal(bool(d::merge(req)))); auto replaced = d::merge(req); - ASSERT_TRUE(replaced) << replaced.error(); -} + if (auto&& failed = expect(fatal(bool(replaced))); !failed) failed << replaced.error(); +}}; // What the metadata states reaches the generated Lua as strings: a quote or a // line break in a name is escaped, never code. -TEST(PackDistribution, XpkgDescriptorWritesMetadataAsStrings) { +UTest xpkg_descriptor_writes_metadata_as_strings{"PackDistribution/XpkgDescriptorWritesMetadataAsStrings", [] { d::Asset a; a.file = "evil-1.0-openkal.zip"; a.top = "evil-1.0-openkal"; @@ -697,15 +696,15 @@ TEST(PackDistribution, XpkgDescriptorWritesMetadataAsStrings) { a.pkg.runner = { "okrun", "{}" }; a.pkg.entry = { { "x86_64", "lib/x86_64/evil.oke" } }; auto text = d::xpkg_descriptor({ a }, "https://example.org/v1.0"); - ASSERT_TRUE(text) << text.error(); - EXPECT_NE(text->find("description = \"ev\\\"il\\010os.exit() 1.0"), std::string::npos) << *text; - EXPECT_EQ(text->find("\nos.exit()"), std::string::npos) << *text; - EXPECT_NE(text->find("deps = { \"xim:okrun\" }"), std::string::npos) << *text; -} + if (auto&& failed = expect(fatal(bool(text))); !failed) failed << text.error(); + if (auto&& failed = expect(ne(text->find("description = \"ev\\\"il\\010os.exit() 1.0"), std::string::npos)); !failed) failed << *text; + if (auto&& failed = expect(eq(text->find("\nos.exit()"), std::string::npos)); !failed) failed << *text; + if (auto&& failed = expect(ne(text->find("deps = { \"xim:okrun\" }"), std::string::npos)); !failed) failed << *text; +}}; // The compression fits the container the merge writes and the name it is // given: zstd in a .tar.gz, or in a zip the first input decided, is refused. -TEST(PackMerge, TheCompressionFitsTheContainer) { +UTest the_compression_fits_the_container{"PackMerge/TheCompressionFitsTheContainer", [] { Tmp t; const auto a = leg_package(t.path / "a", "x86_64-openkal", "x86_64"); const auto b = leg_package(t.path / "b", "aarch64-openkal", "aarch64"); @@ -714,20 +713,20 @@ TEST(PackMerge, TheCompressionFitsTheContainer) { req.compression = *c::parse("zstd"); req.output = t.path / "out.tar.gz"; auto named = d::merge(req); - ASSERT_FALSE(named); - EXPECT_NE(named.error().find(".tar.zst"), std::string::npos) << named.error(); + expect(fatal(!named)); + if (auto&& failed = expect(ne(named.error().find(".tar.zst"), std::string::npos)); !failed) failed << named.error(); req.output = t.path / "out.zip"; - ASSERT_FALSE(d::merge(req)); + expect(fatal(!d::merge(req))); req.output = t.path / "out.tar.zst"; auto fits = d::merge(req); - ASSERT_TRUE(fits) << fits.error(); -} + if (auto&& failed = expect(fatal(bool(fits))); !failed) failed << fits.error(); +}}; // #860 (design 2026-10-29 §4.4): a self-contained tree of images runs where // it carries a runner, and its entry names exactly those pairs -- macOS aarch64 // only, Windows x86_64 only -- with no runner to install. A package that needs // the machine's runner names the pairs the platform's runner exists for. -TEST(PackDistribution, AnEntryNamesWhereThePackageRuns) { +UTest an_entry_names_where_the_package_runs{"PackDistribution/AnEntryNamesWhereThePackageRuns", [] { d::Asset a; a.file = "mcpp-ide-0.1.1-openkal.tar.gz"; a.sha256 = std::string(64, 'c'); @@ -742,43 +741,43 @@ TEST(PackDistribution, AnEntryNamesWhereThePackageRuns) { const std::vector hosts = { "linux-x86_64", "linux-aarch64", "macos-aarch64", "macos-x86_64", "windows-x86_64" }; d::compute_runs_on(a.pkg, hosts, false); - EXPECT_EQ(a.pkg.runsOnSelf, a.pkg.systems); - ASSERT_TRUE(a.pkg.runsOnWith.contains("okrun")); - EXPECT_EQ(a.pkg.runsOnWith.at("okrun"), (std::vector{"macos-x86_64"})); + expect(eq(a.pkg.runsOnSelf, a.pkg.systems)); + expect(fatal(a.pkg.runsOnWith.contains("okrun"))); + expect(eq(a.pkg.runsOnWith.at("okrun"), (std::vector{"macos-x86_64"}))); auto r = d::xpkg_descriptor({ a }, "https://example.org/v0.1.1"); - ASSERT_TRUE(r) << r.error(); + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << r.error(); auto block = [&](std::string_view system) { const auto at = r->find(std::format(" {} = {{", system)); return at == std::string::npos ? std::string() : r->substr(at, r->find(" },", at) - at); }; - EXPECT_NE(block("macosx").find("[\"aarch64\"]"), std::string::npos) << *r; - EXPECT_EQ(block("macosx").find("[\"x86_64\"]"), std::string::npos) << *r; - EXPECT_NE(block("windows").find("[\"x86_64\"]"), std::string::npos) << *r; - EXPECT_EQ(block("windows").find("[\"aarch64\"]"), std::string::npos) << *r; - EXPECT_EQ(r->find("deps = {"), std::string::npos) << *r; + if (auto&& failed = expect(ne(block("macosx").find("[\"aarch64\"]"), std::string::npos)); !failed) failed << *r; + if (auto&& failed = expect(eq(block("macosx").find("[\"x86_64\"]"), std::string::npos)); !failed) failed << *r; + if (auto&& failed = expect(ne(block("windows").find("[\"x86_64\"]"), std::string::npos)); !failed) failed << *r; + if (auto&& failed = expect(eq(block("windows").find("[\"aarch64\"]"), std::string::npos)); !failed) failed << *r; + if (auto&& failed = expect(eq(r->find("deps = {"), std::string::npos)); !failed) failed << *r; a.pkg.mode = "vendored"; a.pkg.systems.clear(); d::compute_runs_on(a.pkg, hosts, false); - EXPECT_TRUE(a.pkg.runsOnSelf.empty()); - EXPECT_EQ(a.pkg.runsOnWith.at("okrun").size(), hosts.size()); + expect(a.pkg.runsOnSelf.empty()); + expect(eq(a.pkg.runsOnWith.at("okrun").size(), hosts.size())); auto v = d::xpkg_descriptor({ a }, "https://example.org/v0.1.1"); - ASSERT_TRUE(v) << v.error(); - EXPECT_NE(v->find("deps = { \"xim:okrun\" }"), std::string::npos) << *v; + if (auto&& failed = expect(fatal(bool(v))); !failed) failed << v.error(); + if (auto&& failed = expect(ne(v->find("deps = { \"xim:okrun\" }"), std::string::npos)); !failed) failed << *v; // A platform that states no hosts for its runner is read as every system // of each architecture, as before runs_on existed. d::compute_runs_on(a.pkg, {}, false); - EXPECT_EQ(a.pkg.runsOnWith.at("okrun").size(), 6u); + expect(eq(a.pkg.runsOnWith.at("okrun").size(), 6u)); // The document carries it, and a reader takes it back. auto j = d::to_json(a.pkg); - EXPECT_NE(j.find("\"runs_on\""), std::string::npos) << j; -} + expect(ne(j.find("\"runs_on\""), std::string::npos)) << j; +}}; // The entry is named after the command a person runs, not the package, and // moves the archive's own top-level directory into place. -TEST(PackDistribution, XpkgDescriptorNamesTheCommandAndTheArchiveTop) { +UTest xpkg_descriptor_names_the_command_and_the_archive_top{"PackDistribution/XpkgDescriptorNamesTheCommandAndTheArchiveTop", [] { d::Asset uni; uni.file = "mcppls-0.0.18-openkal.zip"; uni.sha256 = std::string(64, 'a'); @@ -788,13 +787,13 @@ TEST(PackDistribution, XpkgDescriptorNamesTheCommandAndTheArchiveTop) { uni.pkg.runner = { "okrun", "{}" }; uni.pkg.entry = { { "x86_64", "lib/x86_64/mcppls.oke" }, { "aarch64", "lib/aarch64/mcppls.oke" } }; auto u = d::xpkg_descriptor({ uni }, "https://example.org/v0.0.18"); - ASSERT_TRUE(u) << u.error(); - EXPECT_NE(u->find("name = \"mcppls\""), std::string::npos) << *u; - EXPECT_NE(u->find("xvm.add(\"mcppls\", opts)"), std::string::npos) << *u; - EXPECT_NE(u->find("opts.alias = \"mcppls.exe\""), std::string::npos) << *u; - EXPECT_NE(u->find("for _, ext in ipairs({ \".zip\" }) do"), std::string::npos) << *u; - EXPECT_NE(u->find("local top = pkginfo.install_file()"), std::string::npos) << *u; - EXPECT_NE(u->find("os.mv(top, pkginfo.install_dir())"), std::string::npos) << *u; + if (auto&& failed = expect(fatal(bool(u))); !failed) failed << u.error(); + if (auto&& failed = expect(ne(u->find("name = \"mcppls\""), std::string::npos)); !failed) failed << *u; + if (auto&& failed = expect(ne(u->find("xvm.add(\"mcppls\", opts)"), std::string::npos)); !failed) failed << *u; + if (auto&& failed = expect(ne(u->find("opts.alias = \"mcppls.exe\""), std::string::npos)); !failed) failed << *u; + if (auto&& failed = expect(ne(u->find("for _, ext in ipairs({ \".zip\" }) do"), std::string::npos)); !failed) failed << *u; + if (auto&& failed = expect(ne(u->find("local top = pkginfo.install_file()"), std::string::npos)); !failed) failed << *u; + if (auto&& failed = expect(ne(u->find("os.mv(top, pkginfo.install_dir())"), std::string::npos)); !failed) failed << *u; std::vector per; for (auto [triple, top] : { std::pair{ "x86_64-linux-musl", "okrun-0.1.3-linux-x86_64" }, @@ -810,16 +809,16 @@ TEST(PackDistribution, XpkgDescriptorNamesTheCommandAndTheArchiveTop) { per.push_back(a); } auto r = d::xpkg_descriptor(per, "https://example.org/0.1.3"); - ASSERT_TRUE(r) << r.error(); - EXPECT_NE(r->find("name = \"okrun\""), std::string::npos) << *r; - EXPECT_NE(r->find("for _, ext in ipairs({ \".zip\" }) do"), std::string::npos) << *r; - EXPECT_NE(r->find("path.join(pkginfo.install_dir(), \"bin\")"), std::string::npos) << *r; -} + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << r.error(); + if (auto&& failed = expect(ne(r->find("name = \"okrun\""), std::string::npos)); !failed) failed << *r; + if (auto&& failed = expect(ne(r->find("for _, ext in ipairs({ \".zip\" }) do"), std::string::npos)); !failed) failed << *r; + if (auto&& failed = expect(ne(r->find("path.join(pkginfo.install_dir(), \"bin\")"), std::string::npos)); !failed) failed << *r; +}}; // The install takes the package's directory from the downloaded archive's // name, which is what `mcpp pack` names it after; an archive renamed since // is refused, and so is a file xim does not unpack. -TEST(PackDistribution, XpkgDescriptorInstallsTheDirectoryNamedAfterTheArchive) { +UTest xpkg_descriptor_installs_the_directory_named_after_the_archive{"PackDistribution/XpkgDescriptorInstallsTheDirectoryNamedAfterTheArchive", [] { d::Asset a; a.file = "tool-1.0-linux-x86_64.tar.gz"; a.top = "tool-1.0-linux-x86_64"; @@ -834,33 +833,33 @@ TEST(PackDistribution, XpkgDescriptorInstallsTheDirectoryNamedAfterTheArchive) { w.pkg.legs = { "x86_64-windows-gnu" }; w.pkg.program = "tool.exe"; auto r = d::xpkg_descriptor({ a, w }, "https://example.org/1.0"); - ASSERT_TRUE(r) << r.error(); - EXPECT_NE(r->find("for _, ext in ipairs({ \".tar.gz\", \".zip\" }) do"), std::string::npos) << *r; + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << r.error(); + if (auto&& failed = expect(ne(r->find("for _, ext in ipairs({ \".tar.gz\", \".zip\" }) do"), std::string::npos)); !failed) failed << *r; d::Asset renamed = a; renamed.file = "tool.tar.gz"; auto refused = d::xpkg_descriptor({ renamed }, "https://example.org/1.0"); - ASSERT_FALSE(refused); - EXPECT_NE(refused.error().find("named after the archive"), std::string::npos) << refused.error(); + expect(fatal(!refused)); + if (auto&& failed = expect(ne(refused.error().find("named after the archive"), std::string::npos)); !failed) failed << refused.error(); d::Asset tgz = a; tgz.file = "tool-1.0-linux-x86_64.tgz"; auto t = d::xpkg_descriptor({ tgz }, "https://example.org/1.0"); - ASSERT_TRUE(t) << t.error(); - EXPECT_NE(t->find("for _, ext in ipairs({ \".tgz\" }) do"), std::string::npos) << *t; - EXPECT_EQ(d::archive_directory("app-1.0-openkal.tar.zst"), "app-1.0-openkal"); - EXPECT_EQ(d::archive_directory("app-1.0-openkal"), "app-1.0-openkal"); + if (auto&& failed = expect(fatal(bool(t))); !failed) failed << t.error(); + if (auto&& failed = expect(ne(t->find("for _, ext in ipairs({ \".tgz\" }) do"), std::string::npos)); !failed) failed << *t; + expect(eq(d::archive_directory("app-1.0-openkal.tar.zst"), "app-1.0-openkal")); + expect(eq(d::archive_directory("app-1.0-openkal"), "app-1.0-openkal")); d::Asset okb = a; okb.file = "tool-1.0-linux-x86_64.okb"; auto notArchive = d::xpkg_descriptor({ okb }, "https://example.org/1.0"); - ASSERT_FALSE(notArchive); - EXPECT_NE(notArchive.error().find("not an archive xim unpacks"), std::string::npos) << notArchive.error(); -} + expect(fatal(!notArchive)); + if (auto&& failed = expect(ne(notArchive.error().find("not an archive xim unpacks"), std::string::npos)); !failed) failed << notArchive.error(); +}}; // Packages of two systems that place their program apart -- a Windows one at // the root, a POSIX one under bin/ -- give each system its own directory. -TEST(PackDistribution, XpkgDescriptorNamesEachSystemsProgramDirectory) { +UTest xpkg_descriptor_names_each_systems_program_directory{"PackDistribution/XpkgDescriptorNamesEachSystemsProgramDirectory", [] { std::vector per; for (auto [triple, top, program] : { std::tuple{ "x86_64-linux-musl", "okrun-0.1.3-linux-x86_64", "bin/okrun" }, @@ -877,12 +876,11 @@ TEST(PackDistribution, XpkgDescriptorNamesEachSystemsProgramDirectory) { per.push_back(a); } auto r = d::xpkg_descriptor(per, "https://example.org/0.1.3"); - ASSERT_TRUE(r) << r.error(); - EXPECT_NE(r->find("local bindir = { linux = \"bin\", macosx = \"bin\", windows = \".\" }"), - std::string::npos) << *r; - EXPECT_NE(r->find("path.join(pkginfo.install_dir(), bindir[os.host()])"), std::string::npos) << *r; - EXPECT_NE(r->find("name = \"okrun\""), std::string::npos) << *r; -} + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << r.error(); + if (auto&& failed = expect(ne(r->find("local bindir = { linux = \"bin\", macosx = \"bin\", windows = \".\" }"), std::string::npos)); !failed) failed << *r; + if (auto&& failed = expect(ne(r->find("path.join(pkginfo.install_dir(), bindir[os.host()])"), std::string::npos)); !failed) failed << *r; + if (auto&& failed = expect(ne(r->find("name = \"okrun\""), std::string::npos)); !failed) failed << *r; +}}; // An entry describes packages of one leg for the system their program runs on // (SPEC-014 §8): a Linux, macOS or Windows program, or the carrier of an ok @@ -891,7 +889,7 @@ TEST(PackDistribution, XpkgDescriptorNamesEachSystemsProgramDirectory) { // application, a bare-metal image, an ok image without its carrier -- is // refused, naming the package, the triple and the route that distributes it; // it was written `linux` before. -TEST(PackDistribution, XpkgDescriptorDescribesProgramsOfASystem) { +UTest xpkg_descriptor_describes_programs_of_a_system{"PackDistribution/XpkgDescriptorDescribesProgramsOfASystem", [] { auto one = [](std::string triple, std::string top, std::string program) { d::Asset a; a.file = top + ".tar.gz"; @@ -910,10 +908,10 @@ TEST(PackDistribution, XpkgDescriptorDescribesProgramsOfASystem) { Row{ "x86_64-openkal", "--target openkal" } }) { auto r = d::xpkg_descriptor({ one(triple, std::format("web-0.1.0-{}", triple), "bin/web.js") }, "https://example.org/0.1.0"); - ASSERT_FALSE(r) << triple << ": " << *r; - EXPECT_NE(r.error().find(std::format("is a package of {}, which is no program of Linux, " - "macOS or Windows", triple)), std::string::npos) << r.error(); - EXPECT_NE(r.error().find(says), std::string::npos) << r.error(); + if (auto&& failed = expect(fatal(!r)); !failed) failed << triple << ": " << *r; + if (auto&& failed = expect(ne(r.error().find(std::format("is a package of {}, which is no program of Linux, " + "macOS or Windows", triple)), std::string::npos)); !failed) failed << r.error(); + if (auto&& failed = expect(ne(r.error().find(says), std::string::npos)); !failed) failed << r.error(); } for (auto [triple, system] : { std::pair{ "x86_64-linux-gnu", "linux" }, std::pair{ "aarch64-linux-musl", "linux" }, @@ -921,8 +919,8 @@ TEST(PackDistribution, XpkgDescriptorDescribesProgramsOfASystem) { std::pair{ "x86_64-windows-msvc", "windows" } }) { auto r = d::xpkg_descriptor({ one(triple, std::format("tool-0.1.0-{}", triple), "bin/tool") }, "https://example.org/0.1.0"); - ASSERT_TRUE(r) << triple << ": " << r.error(); - EXPECT_NE(r->find(std::format(" {} = {{", system)), std::string::npos) << *r; + if (auto&& failed = expect(fatal(bool(r))); !failed) failed << triple << ": " << r.error(); + if (auto&& failed = expect(ne(r->find(std::format(" {} = {{", system)), std::string::npos)); !failed) failed << *r; } // A one-leg ok image packed with its carrier on Linux: a Linux program of // the image's architecture, as `systems` records it. @@ -930,20 +928,20 @@ TEST(PackDistribution, XpkgDescriptorDescribesProgramsOfASystem) { carried.pkg.mode = "self-contained"; carried.pkg.systems = { "linux-x86_64" }; auto c = d::xpkg_descriptor({ carried }, "https://example.org/0.1.0"); - ASSERT_TRUE(c) << c.error(); - EXPECT_NE(c->find("archs = {\"x86_64\"}"), std::string::npos) << *c; - EXPECT_NE(c->find(" linux = {"), std::string::npos) << *c; - EXPECT_EQ(c->find("macosx = {"), std::string::npos) << *c; - EXPECT_NE(c->find("app-0.1.0-${arch}-openkal-bundle-all.tar.gz"), std::string::npos) << *c; - EXPECT_NE(c->find("path.join(pkginfo.install_dir(), \"bin\")"), std::string::npos) << *c; + if (auto&& failed = expect(fatal(bool(c))); !failed) failed << c.error(); + if (auto&& failed = expect(ne(c->find("archs = {\"x86_64\"}"), std::string::npos)); !failed) failed << *c; + if (auto&& failed = expect(ne(c->find(" linux = {"), std::string::npos)); !failed) failed << *c; + if (auto&& failed = expect(eq(c->find("macosx = {"), std::string::npos)); !failed) failed << *c; + if (auto&& failed = expect(ne(c->find("app-0.1.0-${arch}-openkal-bundle-all.tar.gz"), std::string::npos)); !failed) failed << *c; + if (auto&& failed = expect(ne(c->find("path.join(pkginfo.install_dir(), \"bin\")"), std::string::npos)); !failed) failed << *c; // ...and on a macOS machine of the other architecture, a macOS program. carried.pkg.systems = { "macos-aarch64" }; carried.pkg.legs = { "aarch64-openkal" }; carried.file = "app-0.1.0-aarch64-openkal-bundle-all.tar.gz"; carried.top = "app-0.1.0-aarch64-openkal-bundle-all"; auto m = d::xpkg_descriptor({ carried }, "https://example.org/0.1.0"); - ASSERT_TRUE(m) << m.error(); - EXPECT_NE(m->find("archs = {\"aarch64\"}"), std::string::npos) << *m; - EXPECT_NE(m->find(" macosx = {"), std::string::npos) << *m; - EXPECT_EQ(m->find("linux = {"), std::string::npos) << *m; -} + if (auto&& failed = expect(fatal(bool(m))); !failed) failed << m.error(); + if (auto&& failed = expect(ne(m->find("archs = {\"aarch64\"}"), std::string::npos)); !failed) failed << *m; + if (auto&& failed = expect(ne(m->find(" macosx = {"), std::string::npos)); !failed) failed << *m; + if (auto&& failed = expect(eq(m->find("linux = {"), std::string::npos)); !failed) failed << *m; +}}; diff --git a/tests/unit/test_pack_interface.cpp b/tests/unit/test_pack_interface.cpp index a5cfadf6b..c92e1dec7 100644 --- a/tests/unit/test_pack_interface.cpp +++ b/tests/unit/test_pack_interface.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pack.interface; import mcpp.modgraph.graph; +using namespace mcpplibs::mtest; + using namespace mcpp::pack; using mcpp::modgraph::Graph; using mcpp::modgraph::ModuleId; @@ -65,60 +67,58 @@ std::vector names(const std::vector& v) { // ─── what travels, and what does not ─────────────────────────────────────── -TEST(InterfaceClosure, PublishesTheRootAndItsInterfacePartitionOnly) { +UTest publishes_the_root_and_its_interface_partition_only{"InterfaceClosure/PublishesTheRootAndItsInterfacePartitionOnly", [] { auto c = interface_closure(library_graph(), "mathkit", "mathkit"); - ASSERT_TRUE(c.has_value()) << (c ? "" : c.error()); - EXPECT_EQ(names(c->published), (std::vector{"api.cppm", "mathkit.cppm"})); -} + if (auto&& failed = expect(fatal(c.has_value())); !failed) failed << (c ? "" : c.error()); + expect(eq(names(c->published), (std::vector{"api.cppm", "mathkit.cppm"}))); +}}; -TEST(InterfaceClosure, WithholdsTheImplementationPartitionSource) { +UTest withholds_the_implementation_partition_source{"InterfaceClosure/WithholdsTheImplementationPartitionSource", [] { // The whole point for a closed-source library: `secret.cppm` produces a // BMI and an object, and its SOURCE must not be published. auto c = interface_closure(library_graph(), "mathkit", "mathkit"); - ASSERT_TRUE(c.has_value()); - EXPECT_EQ(names(c->withheld), - (std::vector{"capi.c", "impl.cpp", "secret.cppm"})); -} + expect(fatal(c.has_value())); + expect(eq(names(c->withheld), (std::vector{"capi.c", "impl.cpp", "secret.cppm"}))); +}}; -TEST(InterfaceClosure, DropSetIsThePublishedObjectsNotEveryModuleObject) { +UTest drop_set_is_the_published_objects_not_every_module_object{"InterfaceClosure/DropSetIsThePublishedObjectsNotEveryModuleObject", [] { // Measured: deleting every `.m.o` also deletes `secret.m.o`, which holds // real code, and every target then fails to link with an undefined // reference that names neither the archive nor the rule that removed it. auto c = interface_closure(library_graph(), "mathkit", "mathkit"); - ASSERT_TRUE(c.has_value()); + expect(fatal(c.has_value())); auto drop = published_object_names(*c); std::ranges::sort(drop); - EXPECT_EQ(drop, (std::vector{"api.m.o", "mathkit.m.o"})); - EXPECT_EQ(std::ranges::find(drop, "secret.m.o"), drop.end()); -} + expect(eq(drop, (std::vector{"api.m.o", "mathkit.m.o"}))); + expect(eq(std::ranges::find(drop, "secret.m.o"), drop.end())); +}}; // ─── the loud half of the asymmetry ──────────────────────────────────────── -TEST(InterfaceClosure, AnInterfaceThatReachesAnImplementationPartitionPublishesItAndSaysSo) { +UTest an_interface_that_reaches_an_implementation_partition_publishes_it_and_says_so{"InterfaceClosure/AnInterfaceThatReachesAnImplementationPartitionPublishesItAndSaysSo", [] { // Legal, and the consumer cannot build the interface's BMI without that // source — so it is published rather than refused. But for a closed-source // library it is the one outcome nobody wants by accident, and `import // :secret;` in an interface reads like any other import, so it is reported. auto c = interface_closure(library_graph(/*interfaceReachesSecret=*/true), "mathkit", "mathkit"); - ASSERT_TRUE(c.has_value()); - EXPECT_EQ(names(c->published), - (std::vector{"api.cppm", "mathkit.cppm", "secret.cppm"})); - ASSERT_EQ(c->publishedImplementationPartitions.size(), 1u); - EXPECT_EQ(c->publishedImplementationPartitions[0].filename().string(), "secret.cppm"); - EXPECT_TRUE(c->unresolvedImports.empty()); -} - -TEST(InterfaceClosure, AnInterfaceOnlyClosureReportsNoPartitionLeak) { + expect(fatal(c.has_value())); + expect(eq(names(c->published), (std::vector{"api.cppm", "mathkit.cppm", "secret.cppm"}))); + expect(fatal(eq(c->publishedImplementationPartitions.size(), 1u))); + expect(eq(c->publishedImplementationPartitions[0].filename().string(), "secret.cppm")); + expect(c->unresolvedImports.empty()); +}}; + +UTest an_interface_only_closure_reports_no_partition_leak{"InterfaceClosure/AnInterfaceOnlyClosureReportsNoPartitionLeak", [] { auto c = interface_closure(library_graph(), "mathkit", "mathkit"); - ASSERT_TRUE(c.has_value()); - EXPECT_TRUE(c->publishedImplementationPartitions.empty()); - EXPECT_TRUE(c->publishedUndeterminedPartitions.empty()); -} + expect(fatal(c.has_value())); + expect(c->publishedImplementationPartitions.empty()); + expect(c->publishedUndeterminedPartitions.empty()); +}}; // ─── the state that used to be spelled "interface" ───────────────────────── -TEST(InterfaceClosure, APublishedPartitionOfUnknownKindIsReportedSeparately) { +UTest a_published_partition_of_unknown_kind_is_reported_separately{"InterfaceClosure/APublishedPartitionOfUnknownKindIsReportedSeparately", [] { // A `[scan_overrides.""]` entry says which module a file provides and // has nowhere to say whether the declaration is exported; a P1689 scanner // may omit `is-interface`. That used to arrive as `providesInterface = @@ -127,18 +127,17 @@ TEST(InterfaceClosure, APublishedPartitionOfUnknownKindIsReportedSeparately) { auto g = library_graph(/*interfaceReachesSecret=*/true); g.units[2].providesInterface.reset(); // src/secret.cppm, kind unknown auto c = interface_closure(g, "mathkit", "mathkit"); - ASSERT_TRUE(c.has_value()); + expect(fatal(c.has_value())); // Still published — the consumer cannot build the root's BMI without it. - EXPECT_EQ(names(c->published), - (std::vector{"api.cppm", "mathkit.cppm", "secret.cppm"})); + expect(eq(names(c->published), (std::vector{"api.cppm", "mathkit.cppm", "secret.cppm"}))); // But reported as undetermined, not as a known implementation partition: // the sentence to print is a different one. - EXPECT_TRUE(c->publishedImplementationPartitions.empty()); - ASSERT_EQ(c->publishedUndeterminedPartitions.size(), 1u); - EXPECT_EQ(c->publishedUndeterminedPartitions[0].filename().string(), "secret.cppm"); -} + expect(c->publishedImplementationPartitions.empty()); + expect(fatal(eq(c->publishedUndeterminedPartitions.size(), 1u))); + expect(eq(c->publishedUndeterminedPartitions[0].filename().string(), "secret.cppm")); +}}; -TEST(InterfaceClosure, AnUndeterminedPrimaryInterfaceIsNotReported) { +UTest an_undetermined_primary_interface_is_not_reported{"InterfaceClosure/AnUndeterminedPrimaryInterfaceIsNotReported", [] { // Only a PARTITION can be either kind. `module M;` provides nothing, so the // only declaration that provides a bare `M` is `export module M;` — asking // the question there would warn about every primary interface in every @@ -147,42 +146,42 @@ TEST(InterfaceClosure, AnUndeterminedPrimaryInterfaceIsNotReported) { auto g = library_graph(); g.units[0].providesInterface.reset(); // src/mathkit.cppm, the root auto c = interface_closure(g, "mathkit", "mathkit"); - ASSERT_TRUE(c.has_value()); - EXPECT_TRUE(c->publishedUndeterminedPartitions.empty()); - EXPECT_TRUE(c->publishedImplementationPartitions.empty()); -} + expect(fatal(c.has_value())); + expect(c->publishedUndeterminedPartitions.empty()); + expect(c->publishedImplementationPartitions.empty()); +}}; -TEST(InterfaceClosure, AWithheldPartitionOfUnknownKindIsNotReportedEither) { +UTest a_withheld_partition_of_unknown_kind_is_not_reported_either{"InterfaceClosure/AWithheldPartitionOfUnknownKindIsNotReportedEither", [] { // The warning is about what is PUBLISHED. `secret.cppm` is unreachable from // the interface here, so its kind never mattered. auto g = library_graph(/*interfaceReachesSecret=*/false); g.units[2].providesInterface.reset(); auto c = interface_closure(g, "mathkit", "mathkit"); - ASSERT_TRUE(c.has_value()); - EXPECT_TRUE(c->publishedUndeterminedPartitions.empty()); -} + expect(fatal(c.has_value())); + expect(c->publishedUndeterminedPartitions.empty()); +}}; -TEST(InterfaceClosure, AGenuinelyMissingPartitionIsStillAnError) { +UTest a_genuinely_missing_partition_is_still_an_error{"InterfaceClosure/AGenuinelyMissingPartitionIsStillAnError", [] { // The unresolved path is not dead: a partition nothing provides means the // published set is incomplete and the consumer's compile will fail on it. auto g = library_graph(/*interfaceReachesSecret=*/true); g.producerOf.erase("mathkit:secret"); auto c = interface_closure(g, "mathkit", "mathkit"); - ASSERT_TRUE(c.has_value()); - ASSERT_EQ(c->unresolvedImports.size(), 1u); - EXPECT_EQ(c->unresolvedImports[0], "mathkit:secret"); -} + expect(fatal(c.has_value())); + expect(fatal(eq(c->unresolvedImports.size(), 1u))); + expect(eq(c->unresolvedImports[0], "mathkit:secret")); +}}; -TEST(InterfaceClosure, ADependencysModuleIsNeitherPublishedNorUnresolved) { +UTest a_dependencys_module_is_neither_published_nor_unresolved{"InterfaceClosure/ADependencysModuleIsNeitherPublishedNorUnresolved", [] { auto g = library_graph(); g.units[0].requires_.push_back(ModuleId{"compat.zlib"}); // someone else's auto c = interface_closure(g, "mathkit", "mathkit"); - ASSERT_TRUE(c.has_value()); - EXPECT_TRUE(c->unresolvedImports.empty()); - EXPECT_EQ(c->published.size(), 2u); -} + expect(fatal(c.has_value())); + expect(c->unresolvedImports.empty()); + expect(eq(c->published.size(), 2u)); +}}; -TEST(InterfaceClosure, ForeignPackageUnitsAreNotFollowed) { +UTest foreign_package_units_are_not_followed{"InterfaceClosure/ForeignPackageUnitsAreNotFollowed", [] { auto g = library_graph(); SourceUnit other; other.path = "vendor/zlib.cppm"; @@ -193,23 +192,23 @@ TEST(InterfaceClosure, ForeignPackageUnitsAreNotFollowed) { g.units[0].requires_.push_back(ModuleId{"compat.zlib"}); auto c = interface_closure(g, "mathkit", "mathkit"); - ASSERT_TRUE(c.has_value()); - EXPECT_EQ(names(c->published), (std::vector{"api.cppm", "mathkit.cppm"})); + expect(fatal(c.has_value())); + expect(eq(names(c->published), (std::vector{"api.cppm", "mathkit.cppm"}))); // Nor does a foreign unit show up as something we withheld. auto withheld = names(c->withheld); - EXPECT_EQ(std::ranges::find(withheld, "zlib.cppm"), withheld.end()); -} + expect(eq(std::ranges::find(withheld, "zlib.cppm"), withheld.end())); +}}; -TEST(InterfaceClosure, RefusesAnUnknownRoot) { +UTest refuses_an_unknown_root{"InterfaceClosure/RefusesAnUnknownRoot", [] { auto c = interface_closure(library_graph(), "mathkit", "nosuch"); - ASSERT_FALSE(c.has_value()); - EXPECT_NE(c.error().find("nosuch"), std::string::npos); -} + expect(fatal(!c.has_value())); + expect(ne(c.error().find("nosuch"), std::string::npos)); +}}; -TEST(InterfaceClosure, RefusesARootOwnedByAnotherPackage) { +UTest refuses_a_root_owned_by_another_package{"InterfaceClosure/RefusesARootOwnedByAnotherPackage", [] { auto g = library_graph(); g.units[0].packageName = "somebody.else"; auto c = interface_closure(g, "mathkit", "mathkit"); - ASSERT_FALSE(c.has_value()); - EXPECT_NE(c.error().find("somebody.else"), std::string::npos); -} + expect(fatal(!c.has_value())); + expect(ne(c.error().find("somebody.else"), std::string::npos)); +}}; diff --git a/tests/unit/test_pack_members.cpp b/tests/unit/test_pack_members.cpp index 7e4c8e5ac..da4f02a63 100644 --- a/tests/unit/test_pack_members.cpp +++ b/tests/unit/test_pack_members.cpp @@ -4,12 +4,14 @@ // several (`with_member`), which `mcpp test` and `mcpp pack` read a member's // closure through. The e2e halves are tests/e2e/867_… to 869_…. -#include import std; +import mcpplibs.mtest; import mcpp.build.plan; import mcpp.build.prepare; +using namespace mcpplibs::mtest; + namespace { using Names = std::vector; @@ -48,81 +50,80 @@ mcpp::build::BuildContext two_members() { // A package acts for the member that is itself, the one member that reaches it, // and none when several do. -TEST(PackOwner, AMemberActsForItself) { - EXPECT_EQ(mcpp::build::pack_owner("a", Names{"a", "b"}), "a"); - EXPECT_EQ(mcpp::build::pack_owner("b", Names{"b"}), "b"); -} +UTest a_member_acts_for_itself{"PackOwner/AMemberActsForItself", [] { + expect(eq(mcpp::build::pack_owner("a", Names{"a", "b"}), "a")); + expect(eq(mcpp::build::pack_owner("b", Names{"b"}), "b")); +}}; -TEST(PackOwner, APackageOneMemberReachesActsForThatMember) { - EXPECT_EQ(mcpp::build::pack_owner("dist", Names{"a"}), "a"); -} +UTest a_package_one_member_reaches_acts_for_that_member{"PackOwner/APackageOneMemberReachesActsForThatMember", [] { + expect(eq(mcpp::build::pack_owner("dist", Names{"a"}), "a")); +}}; -TEST(PackOwner, APackageSeveralMembersReachActsForNone) { - EXPECT_EQ(mcpp::build::pack_owner("core", Names{"a", "b"}), ""); - EXPECT_EQ(mcpp::build::pack_owner("orphan", Names{}), ""); -} +UTest a_package_several_members_reach_acts_for_none{"PackOwner/APackageSeveralMembersReachActsForNone", [] { + expect(eq(mcpp::build::pack_owner("core", Names{"a", "b"}), "")); + expect(eq(mcpp::build::pack_owner("orphan", Names{}), "")); +}}; // The view of a member holds the member's closure, manifest and root, and is // undone when the call returns. -TEST(WithMember, ReadsTheMembersOwnFieldsAndRestoresTheGroups) { +UTest reads_the_members_own_fields_and_restores_the_groups{"WithMember/ReadsTheMembersOwnFieldsAndRestoresTheGroups", [] { auto ctx = two_members(); bool ran = false; mcpp::build::with_member(ctx, "b", [&] { ran = true; - EXPECT_EQ(ctx.manifest.package.name, "b"); - EXPECT_EQ(ctx.projectRoot, std::filesystem::path("/ws/b")); - EXPECT_EQ(ctx.plan.productDir, std::filesystem::path("bin/b")); - ASSERT_EQ(ctx.plan.runtimeLibraryDirs.size(), 1u); - EXPECT_EQ(ctx.plan.runtimeLibraryDirs.front(), std::filesystem::path("/closure/b")); + expect(eq(ctx.manifest.package.name, "b")); + expect(eq(ctx.projectRoot, std::filesystem::path("/ws/b"))); + expect(eq(ctx.plan.productDir, std::filesystem::path("bin/b"))); + expect(fatal(eq(ctx.plan.runtimeLibraryDirs.size(), 1u))); + expect(eq(ctx.plan.runtimeLibraryDirs.front(), std::filesystem::path("/closure/b"))); }); - EXPECT_TRUE(ran); - EXPECT_EQ(ctx.manifest.package.name, "virtual"); - EXPECT_EQ(ctx.projectRoot, std::filesystem::path("/ws")); - EXPECT_EQ(ctx.plan.productDir, std::filesystem::path("bin")); - ASSERT_EQ(ctx.plan.runtimeLibraryDirs.size(), 1u); - EXPECT_EQ(ctx.plan.runtimeLibraryDirs.front(), std::filesystem::path("/plan/lib")); + expect(bool(ran)); + expect(eq(ctx.manifest.package.name, "virtual")); + expect(eq(ctx.projectRoot, std::filesystem::path("/ws"))); + expect(eq(ctx.plan.productDir, std::filesystem::path("bin"))); + expect(fatal(eq(ctx.plan.runtimeLibraryDirs.size(), 1u))); + expect(eq(ctx.plan.runtimeLibraryDirs.front(), std::filesystem::path("/plan/lib"))); // The groups keep what they held, so the next member reads its own. - EXPECT_EQ(ctx.plan.linkGroups[0].runtimeLibraryDirs.front(), std::filesystem::path("/closure/a")); - EXPECT_EQ(ctx.plan.linkGroups[1].runtimeLibraryDirs.front(), std::filesystem::path("/closure/b")); - EXPECT_EQ(ctx.workspaceMembers[1].manifest.package.name, "b"); - EXPECT_EQ(ctx.workspaceMembers[1].root, std::filesystem::path("/ws/b")); -} + expect(eq(ctx.plan.linkGroups[0].runtimeLibraryDirs.front(), std::filesystem::path("/closure/a"))); + expect(eq(ctx.plan.linkGroups[1].runtimeLibraryDirs.front(), std::filesystem::path("/closure/b"))); + expect(eq(ctx.workspaceMembers[1].manifest.package.name, "b")); + expect(eq(ctx.workspaceMembers[1].root, std::filesystem::path("/ws/b"))); +}}; -TEST(WithMember, OneMemberAfterTheOtherEachReadsItsOwn) { +UTest one_member_after_the_other_each_reads_its_own{"WithMember/OneMemberAfterTheOtherEachReadsItsOwn", [] { auto ctx = two_members(); std::vector seen; for (std::string_view m : {"a", "b", "a"}) mcpp::build::with_member(ctx, m, [&] { seen.push_back(ctx.plan.runtimeLibraryDirs.front().generic_string()); }); - EXPECT_EQ(seen, (std::vector{"/closure/a", "/closure/b", "/closure/a"})); -} + expect(eq(seen, (std::vector{"/closure/a", "/closure/b", "/closure/a"}))); +}}; -TEST(WithMember, TheExchangeIsUndoneWhenTheCallThrows) { +UTest the_exchange_is_undone_when_the_call_throws{"WithMember/TheExchangeIsUndoneWhenTheCallThrows", [] { auto ctx = two_members(); - EXPECT_THROW(mcpp::build::with_member(ctx, "a", [&] { throw std::runtime_error("boom"); }), - std::runtime_error); - EXPECT_EQ(ctx.manifest.package.name, "virtual"); - EXPECT_EQ(ctx.projectRoot, std::filesystem::path("/ws")); - EXPECT_EQ(ctx.plan.runtimeLibraryDirs.front(), std::filesystem::path("/plan/lib")); - EXPECT_EQ(ctx.plan.linkGroups[0].runtimeLibraryDirs.front(), std::filesystem::path("/closure/a")); -} + expect(throws([&] { mcpp::build::with_member(ctx, "a", [&] { throw std::runtime_error("boom"); }); })); + expect(eq(ctx.manifest.package.name, "virtual")); + expect(eq(ctx.projectRoot, std::filesystem::path("/ws"))); + expect(eq(ctx.plan.runtimeLibraryDirs.front(), std::filesystem::path("/plan/lib"))); + expect(eq(ctx.plan.linkGroups[0].runtimeLibraryDirs.front(), std::filesystem::path("/closure/a"))); +}}; // No owner, a name the plan does not hold, and a group that places nothing all // read the plan as it is. -TEST(WithMember, APlanWithoutTheMemberIsReadAsItIs) { +UTest a_plan_without_the_member_is_read_as_it_is{"WithMember/APlanWithoutTheMemberIsReadAsItIs", [] { auto ctx = two_members(); for (std::string_view owner : {"", "nosuch"}) { bool ran = false; mcpp::build::with_member(ctx, owner, [&] { ran = true; - EXPECT_EQ(ctx.manifest.package.name, "virtual"); - EXPECT_EQ(ctx.plan.runtimeLibraryDirs.front(), std::filesystem::path("/plan/lib")); + expect(eq(ctx.manifest.package.name, "virtual")); + expect(eq(ctx.plan.runtimeLibraryDirs.front(), std::filesystem::path("/plan/lib"))); }); - EXPECT_TRUE(ran); + expect(bool(ran)); } mcpp::build::BuildContext plain; plain.manifest.package.name = "solo"; - mcpp::build::with_member(plain, "solo", [&] { EXPECT_EQ(plain.manifest.package.name, "solo"); }); - EXPECT_EQ(plain.manifest.package.name, "solo"); -} + mcpp::build::with_member(plain, "solo", [&] { expect(eq(plain.manifest.package.name, "solo")); }); + expect(eq(plain.manifest.package.name, "solo")); +}}; diff --git a/tests/unit/test_pack_modes.cpp b/tests/unit/test_pack_modes.cpp index 49b09aec7..dcc94bab2 100644 --- a/tests/unit/test_pack_modes.cpp +++ b/tests/unit/test_pack_modes.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pack; +using namespace mcpplibs::mtest; + using mcpp::pack::Mode; using mcpp::pack::parse_mode; using mcpp::pack::mode_cli_name; @@ -10,28 +12,28 @@ using mcpp::pack::Format; using mcpp::pack::ClosureUnavailableOutcome; using mcpp::pack::closure_unavailable_outcome; -TEST(PackModes, CanonicalNamesParse) { - EXPECT_EQ(parse_mode("system"), Mode::None); - EXPECT_EQ(parse_mode("vendored"), Mode::BundleProject); - EXPECT_EQ(parse_mode("self-contained"), Mode::BundleAll); - EXPECT_EQ(parse_mode("static"), Mode::Static); -} +UTest canonical_names_parse{"PackModes/CanonicalNamesParse", [] { + expect(eq(parse_mode("system"), Mode::None)); + expect(eq(parse_mode("vendored"), Mode::BundleProject)); + expect(eq(parse_mode("self-contained"), Mode::BundleAll)); + expect(eq(parse_mode("static"), Mode::Static)); +}}; -TEST(PackModes, OldNamesStayAsAliases) { - EXPECT_EQ(parse_mode("bundle-project"), Mode::BundleProject); - EXPECT_EQ(parse_mode("bundle-all"), Mode::BundleAll); -} +UTest old_names_stay_as_aliases{"PackModes/OldNamesStayAsAliases", [] { + expect(eq(parse_mode("bundle-project"), Mode::BundleProject)); + expect(eq(parse_mode("bundle-all"), Mode::BundleAll)); +}}; -TEST(PackModes, UnknownIsNullopt) { - EXPECT_FALSE(parse_mode("nonsense").has_value()); -} +UTest unknown_is_nullopt{"PackModes/UnknownIsNullopt", [] { + expect(!(parse_mode("nonsense").has_value())); +}}; -TEST(PackModes, CliNamesAreCanonical) { - EXPECT_EQ(mode_cli_name(Mode::None), "system"); - EXPECT_EQ(mode_cli_name(Mode::BundleProject), "vendored"); - EXPECT_EQ(mode_cli_name(Mode::BundleAll), "self-contained"); - EXPECT_EQ(mode_cli_name(Mode::Static), "static"); -} +UTest cli_names_are_canonical{"PackModes/CliNamesAreCanonical", [] { + expect(eq(mode_cli_name(Mode::None), "system")); + expect(eq(mode_cli_name(Mode::BundleProject), "vendored")); + expect(eq(mode_cli_name(Mode::BundleAll), "self-contained")); + expect(eq(mode_cli_name(Mode::Static), "static")); +}}; // ── #630 §3: what happens when a format's dependency closure is unavailable, // as a function of the format alone ────────────────────────────────────── @@ -41,34 +43,31 @@ TEST(PackModes, CliNamesAreCanonical) { // ahead of the closure walk. A dispatched format receives the staged tree // regardless: a provider may not need a closure at all. -TEST(PackClosureUnavailableOutcome, ArchiveFormatsFailTheCommand) { - EXPECT_EQ(closure_unavailable_outcome(Format::Tar), - ClosureUnavailableOutcome::CommandFails); - EXPECT_EQ(closure_unavailable_outcome(Format::Dir), - ClosureUnavailableOutcome::CommandFails); -} +UTest archive_formats_fail_the_command{"PackClosureUnavailableOutcome/ArchiveFormatsFailTheCommand", [] { + expect(eq(closure_unavailable_outcome(Format::Tar), ClosureUnavailableOutcome::CommandFails)); + expect(eq(closure_unavailable_outcome(Format::Dir), ClosureUnavailableOutcome::CommandFails)); +}}; -TEST(PackClosureUnavailableOutcome, DispatchedFormatStagesWithoutIt) { - EXPECT_EQ(closure_unavailable_outcome(Format::Dispatched), - ClosureUnavailableOutcome::StageWithoutClosure); -} +UTest dispatched_format_stages_without_it{"PackClosureUnavailableOutcome/DispatchedFormatStagesWithoutIt", [] { + expect(eq(closure_unavailable_outcome(Format::Dispatched), ClosureUnavailableOutcome::StageWithoutClosure)); +}}; // `--systems` (design 2026-10-29 §4.1): `` for every architecture of the // legs, `-` for one, `all` for the three systems; unstated, this // machine's. -TEST(PackSystems, TheWordsNameSystemsForAnArchitecture) { +UTest the_words_name_systems_for_an_architecture{"PackSystems/TheWordsNameSystemsForAnArchitecture", [] { using V = std::vector; - EXPECT_EQ(mcpp::pack::systems_for({ "linux", "macos-aarch64" }, "x86_64"), V{ "linux" }); - EXPECT_EQ(mcpp::pack::systems_for({ "linux", "macos-aarch64" }, "aarch64"), (V{ "linux", "macos" })); - EXPECT_EQ(mcpp::pack::systems_for({ "all", "linux" }, "x86_64"), (V{ "linux", "macos", "windows" })); - EXPECT_EQ(mcpp::pack::systems_for({}, "x86_64").size(), 1u); -} + expect(eq(mcpp::pack::systems_for({ "linux", "macos-aarch64" }, "x86_64"), V{ "linux" })); + expect(eq(mcpp::pack::systems_for({ "linux", "macos-aarch64" }, "aarch64"), (V{ "linux", "macos" }))); + expect(eq(mcpp::pack::systems_for({ "all", "linux" }, "x86_64"), (V{ "linux", "macos", "windows" }))); + expect(eq(mcpp::pack::systems_for({}, "x86_64").size(), 1u)); +}}; // `-o` names a directory when it ends in a separator or is one (design // 2026-10-29 §4.2). -TEST(PackSystems, AnOutputEndingInASeparatorIsADirectory) { - EXPECT_TRUE(mcpp::pack::output_names_directory("dist/")); - EXPECT_FALSE(mcpp::pack::output_names_directory("dist/app.tar.gz")); - EXPECT_FALSE(mcpp::pack::output_names_directory("")); - EXPECT_TRUE(mcpp::pack::output_names_directory(std::filesystem::temp_directory_path())); -} +UTest an_output_ending_in_a_separator_is_a_directory{"PackSystems/AnOutputEndingInASeparatorIsADirectory", [] { + expect(bool(mcpp::pack::output_names_directory("dist/"))); + expect(!mcpp::pack::output_names_directory("dist/app.tar.gz")); + expect(!mcpp::pack::output_names_directory("")); + expect(bool(mcpp::pack::output_names_directory(std::filesystem::temp_directory_path()))); +}}; diff --git a/tests/unit/test_pack_polyglot.cpp b/tests/unit/test_pack_polyglot.cpp index 2c3fa71f3..88866317e 100644 --- a/tests/unit/test_pack_polyglot.cpp +++ b/tests/unit/test_pack_polyglot.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pack.polyglot; +using namespace mcpplibs::mtest; + // A polyglot file's layout (SPEC-014 §5.3). The PE half is asserted end to // end by e2e 992, against the carriers a pack makes; what is asserted here is // the arithmetic a wrong answer to would still run on the machine that wrote @@ -28,52 +30,52 @@ std::uint64_t number_after(std::string_view text, std::string_view key) { } // namespace -TEST(PackPolyglot, WithoutWindowsTheFileIsAScriptAndEachProgramLiesWhereItsLineSays) { +UTest without_windows_the_file_is_a_script_and_each_program_lies_where_its_line_says{"PackPolyglot/WithoutWindowsTheFileIsAScriptAndEachProgramLiesWhereItsLineSays", [] { const std::string linux64(1000, 'L'), mac(777, 'M'); auto file = pg::assemble("app", { program("linux", "x86_64", linux64), program("macos", "aarch64", mac) }); - ASSERT_TRUE(file.has_value()) << file.error(); - EXPECT_TRUE(file->starts_with("#!/bin/sh\no=$0;")); - EXPECT_TRUE(pg::is_polyglot(*file)); + if (auto&& failed = expect(fatal(file.has_value())); !failed) failed << file.error(); + expect(file->starts_with("#!/bin/sh\no=$0;")); + expect(pg::is_polyglot(*file)); // The head names the script: `tail -c +` and `head -c `. const auto at = number_after(*file, "tail -c +") - 1; const auto length = number_after(*file, "|head -c "); - ASSERT_EQ(at + length, file->size()); + expect(fatal(eq(at + length, file->size()))); const std::string_view script = std::string_view(*file).substr(at); - EXPECT_TRUE(script.starts_with("# mcpp polyglot")); - EXPECT_NE(script.find("n='app'"), std::string_view::npos); - EXPECT_EQ(script.find("MINGW*"), std::string_view::npos) << "no Windows program, no MSYS line"; + expect(script.starts_with("# mcpp polyglot")); + expect(ne(script.find("n='app'"), std::string_view::npos)); + expect(eq(script.find("MINGW*"), std::string_view::npos)) << "no Windows program, no MSYS line"; for (auto [key, bytes] : { std::pair{std::string_view("k=linux-x86_64 a="), std::string_view(linux64)}, std::pair{std::string_view("k=macos-aarch64 a="), std::string_view(mac)} }) { const auto line = script.find(key); - ASSERT_NE(line, std::string_view::npos) << key; + expect(fatal(ne(line, std::string_view::npos))) << key; const auto off = number_after(script.substr(line), key); const auto len = number_after(script.substr(line), " b="); - EXPECT_EQ(len, bytes.size()) << key; - EXPECT_EQ(std::string_view(*file).substr(off, len), bytes) << key; + expect(eq(len, bytes.size())) << key; + expect(eq(std::string_view(*file).substr(off, len), bytes)) << key; } // `uname` spells an architecture two ways on some systems. - EXPECT_NE(script.find("Linux-x86_64|Linux-amd64)"), std::string_view::npos); - EXPECT_NE(script.find("Darwin-aarch64|Darwin-arm64)"), std::string_view::npos); -} + expect(ne(script.find("Linux-x86_64|Linux-amd64)"), std::string_view::npos)); + expect(ne(script.find("Darwin-aarch64|Darwin-arm64)"), std::string_view::npos)); +}}; -TEST(PackPolyglot, RefusesWhatItCannotLayOut) { - EXPECT_FALSE(pg::assemble("app", {}).has_value()); - EXPECT_FALSE(pg::assemble("a'b", { program("linux", "x86_64", "x") }).has_value()) +UTest refuses_what_it_cannot_lay_out{"PackPolyglot/RefusesWhatItCannotLayOut", [] { + expect(!(pg::assemble("app", {}).has_value())); + expect(!(pg::assemble("a'b", { program("linux", "x86_64", "x") }).has_value())) << "the name is quoted in the script"; - EXPECT_FALSE(pg::assemble("app", { program("freebsd", "x86_64", "x") }).has_value()); + expect(!(pg::assemble("app", { program("freebsd", "x86_64", "x") }).has_value())); auto notPe = pg::assemble("app", { program("windows", "x86_64", std::string(256, 'x')) }); - ASSERT_FALSE(notPe.has_value()); - EXPECT_NE(notPe.error().find("no PE"), std::string::npos) << notPe.error(); + expect(fatal(!notPe.has_value())); + if (auto&& failed = expect(ne(notPe.error().find("no PE"), std::string::npos)); !failed) failed << notPe.error(); auto twoWindows = pg::assemble("app", { program("windows", "x86_64", "MZ"), program("windows", "aarch64", "MZ") }); - ASSERT_FALSE(twoWindows.has_value()); - EXPECT_NE(twoWindows.error().find("one Windows program"), std::string::npos) << twoWindows.error(); -} + expect(fatal(!twoWindows.has_value())); + if (auto&& failed = expect(ne(twoWindows.error().find("one Windows program"), std::string::npos)); !failed) failed << twoWindows.error(); +}}; -TEST(PackPolyglot, RecognisesItsOwnHeadsOnly) { - EXPECT_TRUE(pg::is_polyglot("MZqFpD='\n ")); - EXPECT_FALSE(pg::is_polyglot("MZ\x90\x00")); - EXPECT_FALSE(pg::is_polyglot("#!/bin/sh\necho hi\n")); - EXPECT_FALSE(pg::is_polyglot("")); -} +UTest recognises_its_own_heads_only{"PackPolyglot/RecognisesItsOwnHeadsOnly", [] { + expect(pg::is_polyglot("MZqFpD='\n ")); + expect(!pg::is_polyglot("MZ\x90\x00")); + expect(!pg::is_polyglot("#!/bin/sh\necho hi\n")); + expect(!pg::is_polyglot("")); +}}; diff --git a/tests/unit/test_pack_relocate.cpp b/tests/unit/test_pack_relocate.cpp index c1146ea70..d8bb1d1cc 100644 --- a/tests/unit/test_pack_relocate.cpp +++ b/tests/unit/test_pack_relocate.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pack.relocate; import mcpp.pack.strip; +using namespace mcpplibs::mtest; + // mcpp.pack.relocate / mcpp.pack.strip — the two halves of "the build machine // does not travel", tested where CI cannot otherwise reach them. // @@ -157,7 +159,7 @@ constexpr std::uint64_t kNull = 0, kNeeded = 1, kStrtab = 5, kSoname = 14, // ── the ELF editor ────────────────────────────────────────────────────── -TEST(PackRelocate, RemovesRunpathFromElf64LittleEndian) { +UTest removes_runpath_from_elf64_little_endian{"PackRelocate/RemovesRunpathFromElf64LittleEndian", [] { auto e = make_elf(/*wide=*/true, /*little=*/true, { {kNeeded, 0}, {kStrtab, 0}, {kRunpath, 0}, {kSoname, 0}, {kNull, 0} }, { "libc.so.6", "/home/builder/.mcpp/store/lib64", "libfoo.so.1" }, @@ -166,29 +168,29 @@ TEST(PackRelocate, RemovesRunpathFromElf64LittleEndian) { auto path = write_temp(e.bytes, "elf64le.so"); auto r = mcpp::pack::relocate::strip_search_paths(path); - ASSERT_TRUE(r.has_value()) << (r ? "" : r.error()); - EXPECT_EQ(r->outcome, mcpp::pack::relocate::Outcome::Removed); - ASSERT_EQ(r->paths.size(), 1u); + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << (r ? "" : r.error()); + expect(eq(r->outcome, mcpp::pack::relocate::Outcome::Removed)); + expect(fatal(eq(r->paths.size(), 1u))); // The report names what was dropped — a log line that says only "removed" // cannot tell a stale store path from `$ORIGIN`. - EXPECT_EQ(r->paths[0], "/home/builder/.mcpp/store/lib64"); + expect(eq(r->paths[0], "/home/builder/.mcpp/store/lib64")); std::ifstream in(path, std::ios::binary); std::string after{ std::istreambuf_iterator(in), {} }; // SAME LENGTH: the freed slot becomes DT_NULL padding, so nothing after // PT_DYNAMIC moves and no offset in the file needs fixing up. - EXPECT_EQ(after.size(), before); + expect(eq(after.size(), before)); auto tags = read_tags(after, e.dynOff, true, true); - EXPECT_EQ(std::ranges::count(tags, kRunpath), 0); + expect(eq(std::ranges::count(tags, kRunpath), 0)); // The other entries survive, in order. Losing DT_SONAME here would make the // library unfindable by the name its consumers link against. - EXPECT_EQ(std::ranges::count(tags, kNeeded), 1); - EXPECT_EQ(std::ranges::count(tags, kSoname), 1); - EXPECT_EQ(std::ranges::count(tags, kStrtab), 1); -} + expect(eq(std::ranges::count(tags, kNeeded), 1)); + expect(eq(std::ranges::count(tags, kSoname), 1)); + expect(eq(std::ranges::count(tags, kStrtab), 1)); +}}; -TEST(PackRelocate, RemovesRpathFromElf32BigEndian) { +UTest removes_rpath_from_elf32_big_endian{"PackRelocate/RemovesRpathFromElf32BigEndian", [] { // No CI job produces this shape, and `--target` can. A width or byte-order // mistake would corrupt the image rather than fail. auto e = make_elf(/*wide=*/false, /*little=*/false, @@ -199,20 +201,20 @@ TEST(PackRelocate, RemovesRpathFromElf32BigEndian) { auto path = write_temp(e.bytes, "elf32be.so"); auto r = mcpp::pack::relocate::strip_search_paths(path); - ASSERT_TRUE(r.has_value()) << (r ? "" : r.error()); - EXPECT_EQ(r->outcome, mcpp::pack::relocate::Outcome::Removed); - ASSERT_EQ(r->paths.size(), 1u); - EXPECT_EQ(r->paths[0], "/home/builder/store/lib"); + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << (r ? "" : r.error()); + expect(eq(r->outcome, mcpp::pack::relocate::Outcome::Removed)); + expect(fatal(eq(r->paths.size(), 1u))); + expect(eq(r->paths[0], "/home/builder/store/lib")); std::ifstream in(path, std::ios::binary); std::string after{ std::istreambuf_iterator(in), {} }; - EXPECT_EQ(after.size(), before); + expect(eq(after.size(), before)); auto tags = read_tags(after, e.dynOff, false, false); - EXPECT_EQ(std::ranges::count(tags, kRpath), 0); - EXPECT_EQ(std::ranges::count(tags, kNeeded), 1); -} + expect(eq(std::ranges::count(tags, kRpath), 0)); + expect(eq(std::ranges::count(tags, kNeeded), 1)); +}}; -TEST(PackRelocate, RemovesBothTagsWhenAnImageCarriesBoth) { +UTest removes_both_tags_when_an_image_carries_both{"PackRelocate/RemovesBothTagsWhenAnImageCarriesBoth", [] { // `Both` is what a linker driven with `--disable-new-dtags` after an // explicit -rpath can produce, and the loader honours DT_RUNPATH there. // Leaving either one behind leaves the defect behind. @@ -223,78 +225,78 @@ TEST(PackRelocate, RemovesBothTagsWhenAnImageCarriesBoth) { auto path = write_temp(e.bytes, "elfboth.so"); auto r = mcpp::pack::relocate::strip_search_paths(path); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(r->outcome, mcpp::pack::relocate::Outcome::Removed); - EXPECT_EQ(r->paths.size(), 2u); + expect(fatal(r.has_value())); + expect(eq(r->outcome, mcpp::pack::relocate::Outcome::Removed)); + expect(eq(r->paths.size(), 2u)); std::ifstream in(path, std::ios::binary); std::string after{ std::istreambuf_iterator(in), {} }; auto tags = read_tags(after, e.dynOff, true, true); - EXPECT_EQ(std::ranges::count(tags, kRpath), 0); - EXPECT_EQ(std::ranges::count(tags, kRunpath), 0); -} + expect(eq(std::ranges::count(tags, kRpath), 0)); + expect(eq(std::ranges::count(tags, kRunpath), 0)); +}}; -TEST(PackRelocate, AlreadyCleanIsNotTheSameAsNotChecked) { +UTest already_clean_is_not_the_same_as_not_checked{"PackRelocate/AlreadyCleanIsNotTheSameAsNotChecked", [] { auto e = make_elf(true, true, { {kNeeded, 0}, {kStrtab, 0}, {kNull, 0} }, { "libc.so.6" }, { 0 }); auto path = write_temp(e.bytes, "elfclean.so"); auto r = mcpp::pack::relocate::strip_search_paths(path); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(r->outcome, mcpp::pack::relocate::Outcome::NothingToDo); - EXPECT_TRUE(r->paths.empty()); -} + expect(fatal(r.has_value())); + expect(eq(r->outcome, mcpp::pack::relocate::Outcome::NothingToDo)); + expect(r->paths.empty()); +}}; -TEST(PackRelocate, AStaticArchiveIsNotApplicableRatherThanAnError) { +UTest a_static_archive_is_not_applicable_rather_than_an_error{"PackRelocate/AStaticArchiveIsNotApplicableRatherThanAnError", [] { // Every leg goes through this step, and a `.a` has no dynamic section to // begin with. Reporting that as a failure would make the caller branch on // the format before calling — which is the coupling this module removes. auto path = write_temp("!\n/ 0 0 0 0 4 `\n", "libfoo.a"); auto r = mcpp::pack::relocate::strip_search_paths(path); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(r->outcome, mcpp::pack::relocate::Outcome::NotApplicable); -} + expect(fatal(r.has_value())); + expect(eq(r->outcome, mcpp::pack::relocate::Outcome::NotApplicable)); +}}; -TEST(PackRelocate, AMissingFileIsAnError) { +UTest a_missing_file_is_an_error{"PackRelocate/AMissingFileIsAnError", [] { auto r = mcpp::pack::relocate::strip_search_paths( std::filesystem::temp_directory_path() / "mcpp-relocate-test" / "nope.so"); - EXPECT_FALSE(r.has_value()); -} + expect(!r.has_value()); +}}; // ── the strip table ───────────────────────────────────────────────────── -TEST(PackStrip, StaticArchiveKeepsItsSymbolIndex) { +UTest static_archive_keeps_its_symbol_index{"PackStrip/StaticArchiveKeepsItsSymbolIndex", [] { // THE MEASUREMENT BEHIND THIS TEST. `strip --strip-all` on a `.a` // removes the archive symbol index, and the consumer's link then fails // with `archive has no index; run ranlib to add one` — a message that // names neither strip nor the publisher. `--strip-debug` keeps it // (measured: 2988 -> 1244 bytes, and the consumer links and runs). auto args = mcpp::pack::strip_args(mcpp::pack::ArtifactShape::StaticArchive); - EXPECT_NE(std::ranges::find(args, "--strip-debug"), args.end()); - EXPECT_EQ(std::ranges::find(args, "--strip-all"), args.end()); - EXPECT_EQ(std::ranges::find(args, "--strip-unneeded"), args.end()); + expect(ne(std::ranges::find(args, "--strip-debug"), args.end())); + expect(eq(std::ranges::find(args, "--strip-all"), args.end())); + expect(eq(std::ranges::find(args, "--strip-unneeded"), args.end())); // Two identical packs should produce identical bytes; this is the one // place the packer rewrites an archive. - EXPECT_NE(std::ranges::find(args, "--enable-deterministic-archives"), args.end()); -} + expect(ne(std::ranges::find(args, "--enable-deterministic-archives"), args.end())); +}}; -TEST(PackStrip, SharedLibraryKeepsItsDynamicSymbols) { +UTest shared_library_keeps_its_dynamic_symbols{"PackStrip/SharedLibraryKeepsItsDynamicSymbols", [] { // `--strip-unneeded` removes `.symtab` and keeps `.dynsym`, which IS the // export list. `--strip-all` would also keep `.dynsym`, but dh_strip uses // the narrower flag and there is no reason to be broader than the // reference implementation of this exact decision. auto args = mcpp::pack::strip_args(mcpp::pack::ArtifactShape::SharedLibrary); - EXPECT_NE(std::ranges::find(args, "--strip-unneeded"), args.end()); - EXPECT_EQ(std::ranges::find(args, "--strip-all"), args.end()); - EXPECT_EQ(std::ranges::find(args, "--strip-debug"), args.end()); -} + expect(ne(std::ranges::find(args, "--strip-unneeded"), args.end())); + expect(eq(std::ranges::find(args, "--strip-all"), args.end())); + expect(eq(std::ranges::find(args, "--strip-debug"), args.end())); +}}; -TEST(PackStrip, ExecutableIsStrippedWhole) { +UTest executable_is_stripped_whole{"PackStrip/ExecutableIsStrippedWhole", [] { auto args = mcpp::pack::strip_args(mcpp::pack::ArtifactShape::Executable); - EXPECT_NE(std::ranges::find(args, "--strip-all"), args.end()); -} + expect(ne(std::ranges::find(args, "--strip-all"), args.end())); +}}; -TEST(PackStrip, EveryShapeDropsCommentAndNote) { +UTest every_shape_drops_comment_and_note{"PackStrip/EveryShapeDropsCommentAndNote", [] { // dh_strip drops both from all three. `.note` is matched by exact name, so // `.note.gnu.build-id` survives — which is what `--add-gnu-debuglink` and // debuginfod pair with. @@ -302,12 +304,12 @@ TEST(PackStrip, EveryShapeDropsCommentAndNote) { mcpp::pack::ArtifactShape::SharedLibrary, mcpp::pack::ArtifactShape::StaticArchive }) { auto args = mcpp::pack::strip_args(shape); - EXPECT_NE(std::ranges::find(args, "--remove-section=.comment"), args.end()); - EXPECT_NE(std::ranges::find(args, "--remove-section=.note"), args.end()); + expect(ne(std::ranges::find(args, "--remove-section=.comment"), args.end())); + expect(ne(std::ranges::find(args, "--remove-section=.note"), args.end())); } -} +}}; -TEST(PackStrip, WhetherStrippingAppliesIsAskedOfTheTargetNotTheCompiler) { +UTest whether_stripping_applies_is_asked_of_the_target_not_the_compiler{"PackStrip/WhetherStrippingAppliesIsAskedOfTheTargetNotTheCompiler", [] { // Keying this on the compiler is wrong in BOTH directions, and each // direction is a real configuration mcpp ships: // @@ -319,20 +321,20 @@ TEST(PackStrip, WhetherStrippingAppliesIsAskedOfTheTargetNotTheCompiler) { // `mcpp pack` on a Mac — for a format // whose image carries a debug MAP and // leaves the DWARF in the .o files. - EXPECT_TRUE (mcpp::pack::debug_info_is_in_band("x86_64-linux-gnu")); - EXPECT_TRUE (mcpp::pack::debug_info_is_in_band("aarch64-linux-musl")); - EXPECT_TRUE (mcpp::pack::debug_info_is_in_band("x86_64-windows-gnu")); - EXPECT_FALSE(mcpp::pack::debug_info_is_in_band("x86_64-windows-msvc")); - EXPECT_FALSE(mcpp::pack::debug_info_is_in_band("aarch64-macos")); - EXPECT_FALSE(mcpp::pack::debug_info_is_in_band("x86_64-macos")); + expect(mcpp::pack::debug_info_is_in_band("x86_64-linux-gnu")); + expect(mcpp::pack::debug_info_is_in_band("aarch64-linux-musl")); + expect(mcpp::pack::debug_info_is_in_band("x86_64-windows-gnu")); + expect(!mcpp::pack::debug_info_is_in_band("x86_64-windows-msvc")); + expect(!mcpp::pack::debug_info_is_in_band("aarch64-macos")); + expect(!mcpp::pack::debug_info_is_in_band("x86_64-macos")); // iOS carries the same Mach-O debug map + out-of-band .dSYM as macOS. - EXPECT_FALSE(mcpp::pack::debug_info_is_in_band("aarch64-ios")); + expect(!mcpp::pack::debug_info_is_in_band("aarch64-ios")); // Segment-wise, not substring: mcpp has been bitten by a triple predicate // that answered on a substring before. - EXPECT_TRUE(mcpp::pack::debug_info_is_in_band("macos64-linux-gnu")); -} + expect(mcpp::pack::debug_info_is_in_band("macos64-linux-gnu")); +}}; -TEST(PackStrip, MsvcHasNothingInBandToRemove) { +UTest msvc_has_nothing_in_band_to_remove{"PackStrip/MsvcHasNothingInBandToRemove", [] { // PE/MSVC keeps debug information in a separate `.pdb`. An empty `strip` // tool there is the right answer, not a missing dependency — and the two // must not be conflated, or every other platform gets a silent no-op. @@ -340,15 +342,15 @@ TEST(PackStrip, MsvcHasNothingInBandToRemove) { mcpp::pack::StripTools tools{ {}, {}, /*inBandDebugInfo=*/false }; auto r = mcpp::pack::strip_artifact(path, mcpp::pack::ArtifactShape::SharedLibrary, tools, {}); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(r->outcome, mcpp::pack::StripOutcome::NotApplicable); -} + expect(fatal(r.has_value())); + expect(eq(r->outcome, mcpp::pack::StripOutcome::NotApplicable)); +}}; -TEST(PackStrip, AMissingStripToolIsRefusedWhereDebugInfoIsInBand) { +UTest a_missing_strip_tool_is_refused_where_debug_info_is_in_band{"PackStrip/AMissingStripToolIsRefusedWhereDebugInfoIsInBand", [] { auto path = write_temp("not an image", "notanimage2.bin"); mcpp::pack::StripTools tools{ {}, {}, /*inBandDebugInfo=*/true }; auto r = mcpp::pack::strip_artifact(path, mcpp::pack::ArtifactShape::SharedLibrary, tools, {}); - ASSERT_FALSE(r.has_value()); - EXPECT_NE(r.error().find("no `strip`"), std::string::npos); -} + expect(fatal(!r.has_value())); + expect(ne(r.error().find("no `strip`"), std::string::npos)); +}}; diff --git a/tests/unit/test_pack_route.cpp b/tests/unit/test_pack_route.cpp index 92dcf8485..8d57b5a2f 100644 --- a/tests/unit/test_pack_route.cpp +++ b/tests/unit/test_pack_route.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pack.route; +using namespace mcpplibs::mtest; + using namespace mcpp::pack; // #630 A9: the route is chosen by the artifact's FORM, not by the target's @@ -20,45 +22,45 @@ PackRoute library_route() { return PackRoute{ "mylib", /*library=*/true, /*isAp } // namespace -TEST(AcceptsSeveralTargets, AnAppOnSeveralAndroidRowsIsAccepted) { +UTest an_app_on_several_android_rows_is_accepted{"AcceptsSeveralTargets/AnAppOnSeveralAndroidRowsIsAccepted", [] { std::vector triples{"aarch64-linux-android", "x86_64-linux-android"}; - EXPECT_TRUE(accepts_several_targets(app_route(), triples)); -} + expect(bool(accepts_several_targets(app_route(), triples))); +}}; -TEST(AcceptsSeveralTargets, AnAppMixedWithAnExecutableRowIsRefused) { +UTest an_app_mixed_with_an_executable_row_is_refused{"AcceptsSeveralTargets/AnAppMixedWithAnExecutableRowIsRefused", [] { // The negative direction inside "an app": one Android row and one row // whose form is an executable must not slip through as "every row is a // shared object" — a mixed request is not a request this route can // satisfy either. std::vector triples{"aarch64-linux-android", "x86_64-linux-gnu"}; - EXPECT_FALSE(accepts_several_targets(app_route(), triples)); -} + expect(!(accepts_several_targets(app_route(), triples))); +}}; -TEST(AcceptsSeveralTargets, AnAppOnExecutableRowsOnlyIsRefused) { +UTest an_app_on_executable_rows_only_is_refused{"AcceptsSeveralTargets/AnAppOnExecutableRowsOnlyIsRefused", [] { std::vector triples{"x86_64-linux-gnu", "aarch64-macos"}; - EXPECT_FALSE(accepts_several_targets(app_route(), triples)); -} + expect(!(accepts_several_targets(app_route(), triples))); +}}; -TEST(AcceptsSeveralTargets, ABinTargetIsAlwaysRefusedEvenOnAndroidRows) { +UTest a_bin_target_is_always_refused_even_on_android_rows{"AcceptsSeveralTargets/ABinTargetIsAlwaysRefusedEvenOnAndroidRows", [] { // `kind = "bin"` never resolves to a shared-object form on any row // (`toolchain::triple::application_form` only answers `SharedObject` for // `kind = "app"`), so the several-triple route is not this target's, // whatever triples are named. std::vector triples{"aarch64-linux-android", "x86_64-linux-android"}; - EXPECT_FALSE(accepts_several_targets(bin_route(), triples)); -} + expect(!(accepts_several_targets(bin_route(), triples))); +}}; -TEST(AcceptsSeveralTargets, AnUnparseableTripleIsRefusedNotIgnored) { +UTest an_unparseable_triple_is_refused_not_ignored{"AcceptsSeveralTargets/AnUnparseableTripleIsRefusedNotIgnored", [] { std::vector triples{"aarch64-linux-android", "not a triple"}; - EXPECT_FALSE(accepts_several_targets(app_route(), triples)); -} + expect(!(accepts_several_targets(app_route(), triples))); +}}; -TEST(AcceptsSeveralTargets, ALibraryRouteIsUnaffected) { +UTest a_library_route_is_unaffected{"AcceptsSeveralTargets/ALibraryRouteIsUnaffected", [] { // `accepts_several_targets` is never consulted for a library route in // `cmd_pack` (libraries already take several triples unconditionally); // asked anyway, it answers false because `isApplication` is false for a // library `PackRoute`, which keeps the predicate honest about what it // actually decides. std::vector triples{"aarch64-linux-android", "x86_64-linux-android"}; - EXPECT_FALSE(accepts_several_targets(library_route(), triples)); -} + expect(!(accepts_several_targets(library_route(), triples))); +}}; diff --git a/tests/unit/test_pack_stage_tree.cpp b/tests/unit/test_pack_stage_tree.cpp index 07cf81124..4d8cb6140 100644 --- a/tests/unit/test_pack_stage_tree.cpp +++ b/tests/unit/test_pack_stage_tree.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pack.stage_tree; +using namespace mcpplibs::mtest; + // The staged tree is what `mcpp pack` computes and, until `${mcpp.stage_dir}`, // then threw away. Two things about it are the engine's contract with a // distribution member, and both are asserted here rather than end to end, @@ -37,57 +39,57 @@ std::string read_file(const std::filesystem::path& p) { } // namespace -TEST(PackStageTree, TheManifestIsASiblingAndNeverAMember) { +UTest the_manifest_is_a_sibling_and_never_a_member{"PackStageTree/TheManifestIsASiblingAndNeverAMember", [] { Tmp t; auto stage = t.path / "app-1.0.0-x86_64-linux-gnu"; std::filesystem::create_directories(stage); auto manifest = mcpp::pack::stage_manifest_path(stage); - EXPECT_EQ(manifest.parent_path(), stage.parent_path()); + expect(eq(manifest.parent_path(), stage.parent_path())); // A file INSIDE the tree would be collected by every format that packages // the directory wholesale, and would then ship inside the user's // installer. Asserted as a path relationship because that is the property, // and a spelling change that broke it would otherwise only show up as an // extra file in a released package. - EXPECT_FALSE(manifest.string().starts_with(stage.string() + "/")); - EXPECT_FALSE(manifest.string().starts_with(stage.string() + "\\")); + expect(!(manifest.string().starts_with(stage.string() + "/"))); + expect(!(manifest.string().starts_with(stage.string() + "\\"))); - ASSERT_TRUE(mcpp::pack::write_stage_manifest(stage)); + expect(fatal(bool(mcpp::pack::write_stage_manifest(stage)))); std::error_code ec; for (auto const& e : std::filesystem::recursive_directory_iterator(stage, ec)) - FAIL() << "the manifest landed inside the tree: " << e.path().string(); -} + fail([&] { std::ostringstream text; text << "the manifest landed inside the tree: " << e.path().string(); return text.str(); }()); +}}; -TEST(PackStageTree, TheManifestChangesWhenTheStagedSetDoes) { +UTest the_manifest_changes_when_the_staged_set_does{"PackStageTree/TheManifestChangesWhenTheStagedSetDoes", [] { Tmp t; auto stage = t.path / "app"; write_file(stage / "bin" / "app", "0123456789"); - ASSERT_TRUE(mcpp::pack::write_stage_manifest(stage)); + expect(fatal(bool(mcpp::pack::write_stage_manifest(stage)))); const auto first = read_file(mcpp::pack::stage_manifest_path(stage)); - EXPECT_NE(first.find("bin/app"), std::string::npos); - EXPECT_NE(first.find("10 "), std::string::npos); + expect(ne(first.find("bin/app"), std::string::npos)); + expect(ne(first.find("10 "), std::string::npos)); // A DEPENDENCY'S SHARED LIBRARY JOINING THE CLOSURE. This is the case the // manifest exists for: the program's own bytes need not have changed, so an // edge that depended only on the link output would report the previous // distributable as up to date. write_file(stage / "lib" / "libdep.so.1", "xx"); - ASSERT_TRUE(mcpp::pack::write_stage_manifest(stage)); + expect(fatal(bool(mcpp::pack::write_stage_manifest(stage)))); const auto second = read_file(mcpp::pack::stage_manifest_path(stage)); - EXPECT_NE(first, second); - EXPECT_NE(second.find("lib/libdep.so.1"), std::string::npos); + expect(ne(first, second)); + expect(ne(second.find("lib/libdep.so.1"), std::string::npos)); // A staged file whose LENGTH changed, with no entry added or removed. write_file(stage / "bin" / "app", "0123456789abcdef"); - ASSERT_TRUE(mcpp::pack::write_stage_manifest(stage)); - EXPECT_NE(read_file(mcpp::pack::stage_manifest_path(stage)), second); -} + expect(fatal(bool(mcpp::pack::write_stage_manifest(stage)))); + expect(ne(read_file(mcpp::pack::stage_manifest_path(stage)), second)); +}}; -TEST(PackStageTree, StagingTheSameTreeTwiceLeavesTheManifestUntouched) { +UTest staging_the_same_tree_twice_leaves_the_manifest_untouched{"PackStageTree/StagingTheSameTreeTwiceLeavesTheManifestUntouched", [] { Tmp t; auto stage = t.path / "app"; write_file(stage / "bin" / "app", "same"); - ASSERT_TRUE(mcpp::pack::write_stage_manifest(stage)); + expect(fatal(bool(mcpp::pack::write_stage_manifest(stage)))); auto manifest = mcpp::pack::stage_manifest_path(stage); const auto before = std::filesystem::last_write_time(manifest); @@ -95,11 +97,11 @@ TEST(PackStageTree, StagingTheSameTreeTwiceLeavesTheManifestUntouched) { // input is indistinguishable from a changed input: a pack that staged the // same tree twice would rebuild the distributable both times. Same rule // `mcpp.build.stage` states at length for a staged BMI. - ASSERT_TRUE(mcpp::pack::write_stage_manifest(stage)); - EXPECT_EQ(std::filesystem::last_write_time(manifest), before); -} + expect(fatal(bool(mcpp::pack::write_stage_manifest(stage)))); + expect(eq(std::filesystem::last_write_time(manifest), before)); +}}; -TEST(PackStageTree, TheOrderOfADirectoryWalkIsNotAPromise) { +UTest the_order_of_a_directory_walk_is_not_a_promise{"PackStageTree/TheOrderOfADirectoryWalkIsNotAPromise", [] { // Two trees with the same contents produce the same bytes. Without the // sort, an iteration order that differed between runs would make the dist // edge dirty on every pack for no reason -- which reads as "packaging is @@ -109,13 +111,12 @@ TEST(PackStageTree, TheOrderOfADirectoryWalkIsNotAPromise) { write_file(root / "s" / "bin" / "app", "aa"); write_file(root / "s" / "lib" / "z.so", "bbb"); write_file(root / "s" / "share" / "doc" / "readme", "c"); - ASSERT_TRUE(mcpp::pack::write_stage_manifest(root / "s")); + expect(fatal(bool(mcpp::pack::write_stage_manifest(root / "s")))); } - EXPECT_EQ(read_file(mcpp::pack::stage_manifest_path(a.path / "s")), - read_file(mcpp::pack::stage_manifest_path(b.path / "s"))); -} + expect(eq(read_file(mcpp::pack::stage_manifest_path(a.path / "s")), read_file(mcpp::pack::stage_manifest_path(b.path / "s")))); +}}; -TEST(PackStageTree, TheEngineOwnsFiveFormatNames) { +UTest the_engine_owns_five_format_names{"PackStageTree/TheEngineOwnsFiveFormatNames", [] { // `tar`, `zip`, `dir`, `single-file` and `polyglot` are the shapes `mcpp pack` owns // (SPEC-014 §1.3); every other value of `--pkg-format` is a name a // package provides. The list lives beside the staged tree because two @@ -123,13 +124,13 @@ TEST(PackStageTree, TheEngineOwnsFiveFormatNames) { // unknown name, and the declaration check, which warns of a package that // claims one of them and would therefore be silently unreachable. for (auto name : { "tar", "zip", "dir", "single-file", "polyglot" }) - EXPECT_TRUE(mcpp::pack::is_builtin_pack_format(name)) << name; - EXPECT_FALSE(mcpp::pack::is_builtin_pack_format("appimage")); - EXPECT_FALSE(mcpp::pack::is_builtin_pack_format("okb")); - EXPECT_FALSE(mcpp::pack::is_builtin_pack_format("msi")); - EXPECT_FALSE(mcpp::pack::is_builtin_pack_format("")); - EXPECT_EQ(mcpp::pack::kBuiltinPackFormats.size(), 5u); -} + expect(mcpp::pack::is_builtin_pack_format(name)) << name; + expect(!mcpp::pack::is_builtin_pack_format("appimage")); + expect(!mcpp::pack::is_builtin_pack_format("okb")); + expect(!mcpp::pack::is_builtin_pack_format("msi")); + expect(!mcpp::pack::is_builtin_pack_format("")); + expect(eq(mcpp::pack::kBuiltinPackFormats.size(), 5u)); +}}; // ── #630 §3: the `closure` field ───────────────────────────────────────── // @@ -139,17 +140,17 @@ TEST(PackStageTree, TheEngineOwnsFiveFormatNames) { // comment). What CAN be tested here, on every host, is the writer/reader // contract itself: both values round-trip through the manifest file. -TEST(PackStageTree, DefaultClosureIsWalkedAndSaysSo) { +UTest default_closure_is_walked_and_says_so{"PackStageTree/DefaultClosureIsWalkedAndSaysSo", [] { Tmp t; auto stage = t.path / "app"; write_file(stage / "bin" / "app", "x"); - ASSERT_TRUE(mcpp::pack::write_stage_manifest(stage)); + expect(fatal(bool(mcpp::pack::write_stage_manifest(stage)))); auto text = read_file(mcpp::pack::stage_manifest_path(stage)); - EXPECT_EQ(text.substr(0, text.find('\n')), "closure = walked"); - EXPECT_EQ(text.find("reason ="), std::string::npos); -} + expect(eq(text.substr(0, text.find('\n')), "closure = walked")); + expect(eq(text.find("reason ="), std::string::npos)); +}}; -TEST(PackStageTree, NotWalkedRecordsTheReasonOnOneLine) { +UTest not_walked_records_the_reason_on_one_line{"PackStageTree/NotWalkedRecordsTheReasonOnOneLine", [] { Tmp t; auto stage = t.path / "app"; write_file(stage / "bin" / "app", "x"); @@ -160,36 +161,36 @@ TEST(PackStageTree, NotWalkedRecordsTheReasonOnOneLine) { "by running the artifact under\n" " the target's own dynamic linker.", }; - ASSERT_TRUE(mcpp::pack::write_stage_manifest(stage, closure)); + expect(fatal(bool(mcpp::pack::write_stage_manifest(stage, closure)))); auto text = read_file(mcpp::pack::stage_manifest_path(stage)); auto firstLine = text.substr(0, text.find('\n')); - EXPECT_EQ(firstLine, "closure = not-walked"); + expect(eq(firstLine, "closure = not-walked")); // Folded to ONE line -- the manifest is one entry per line, and the // reason text carries its own embedded newlines because it doubles as a // CLI diagnostic. auto secondLineEnd = text.find('\n', text.find('\n') + 1); auto secondLine = text.substr(text.find('\n') + 1, secondLineEnd - text.find('\n') - 1); - EXPECT_TRUE(secondLine.starts_with("reason = cannot package the Mach-O program")); - EXPECT_EQ(secondLine.find('\n'), std::string::npos); + expect(secondLine.starts_with("reason = cannot package the Mach-O program")); + expect(eq(secondLine.find('\n'), std::string::npos)); // The rest of the manifest -- the staged file listing -- is unaffected. - EXPECT_NE(text.find("bin/app"), std::string::npos); -} + expect(ne(text.find("bin/app"), std::string::npos)); +}}; -TEST(PackStageTree, WalkedAndNotWalkedProduceDifferentManifests) { +UTest walked_and_not_walked_produce_different_manifests{"PackStageTree/WalkedAndNotWalkedProduceDifferentManifests", [] { Tmp t; auto stage = t.path / "app"; write_file(stage / "bin" / "app", "x"); - ASSERT_TRUE(mcpp::pack::write_stage_manifest(stage, mcpp::pack::ClosureStatus{true, ""})); + expect(fatal(bool(mcpp::pack::write_stage_manifest(stage, mcpp::pack::ClosureStatus{true, ""})))); auto walked = read_file(mcpp::pack::stage_manifest_path(stage)); - ASSERT_TRUE(mcpp::pack::write_stage_manifest( - stage, mcpp::pack::ClosureStatus{false, "no such linker to ask"})); + expect(fatal(bool(mcpp::pack::write_stage_manifest( + stage, mcpp::pack::ClosureStatus{false, "no such linker to ask"})))); auto notWalked = read_file(mcpp::pack::stage_manifest_path(stage)); - EXPECT_NE(walked, notWalked); -} + expect(ne(walked, notWalked)); +}}; // ── #634 A3: the `needs` lines ────────────────────────────────────────── -TEST(PackStageTree, NeedsLinesFollowTheHeaderSortedAndOnceEach) { +UTest needs_lines_follow_the_header_sorted_and_once_each{"PackStageTree/NeedsLinesFollowTheHeaderSortedAndOnceEach", [] { Tmp t; auto stage = t.path / "app"; write_file(stage / "lib" / "libfw.so", "fw"); @@ -202,16 +203,15 @@ TEST(PackStageTree, NeedsLinesFollowTheHeaderSortedAndOnceEach) { {"libc.so", K::Platform, {}}, {"libc.so", K::Platform, {}}, }; - ASSERT_TRUE(mcpp::pack::write_stage_manifest(stage, closure)); + expect(fatal(bool(mcpp::pack::write_stage_manifest(stage, closure)))); auto text = read_file(mcpp::pack::stage_manifest_path(stage)); - EXPECT_EQ(text, - "closure = walked\n" + expect(eq(text, "closure = walked\n" "needs\tlibc.so\tplatform\n" "needs\tlibfw.so\tlib/libfw.so\n" - "2 lib/libfw.so\n"); -} + "2 lib/libfw.so\n")); +}}; -TEST(PackStageTree, AnIncompleteClosureListsItsUnresolvedNamesAfterTheReason) { +UTest an_incomplete_closure_lists_its_unresolved_names_after_the_reason{"PackStageTree/AnIncompleteClosureListsItsUnresolvedNamesAfterTheReason", [] { Tmp t; auto stage = t.path / "app"; write_file(stage / "bin" / "app", "x"); @@ -220,61 +220,59 @@ TEST(PackStageTree, AnIncompleteClosureListsItsUnresolvedNamesAfterTheReason) { closure.walked = false; closure.reason = "the dependency closure of 'app' is incomplete"; closure.needs = {{"@rpath/libgone.dylib", K::Unresolved, {}}}; - ASSERT_TRUE(mcpp::pack::write_stage_manifest(stage, closure)); + expect(fatal(bool(mcpp::pack::write_stage_manifest(stage, closure)))); auto text = read_file(mcpp::pack::stage_manifest_path(stage)); - EXPECT_EQ(text, - "closure = not-walked\n" + expect(eq(text, "closure = not-walked\n" "reason = the dependency closure of 'app' is incomplete\n" "needs\t@rpath/libgone.dylib\tunresolved\n" - "1 bin/app\n"); -} + "1 bin/app\n")); +}}; -TEST(PackStageTree, ANameWithASpaceStaysOneField) { +UTest a_name_with_a_space_stays_one_field{"PackStageTree/ANameWithASpaceStaysOneField", [] { using K = mcpp::pack::ClosureNeed::Kind; - EXPECT_EQ(mcpp::pack::render_closure_need( - {"@rpath/My Kit.framework/My Kit", K::Staged, "bin/My Kit"}), - "needs\t@rpath/My Kit.framework/My Kit\tbin/My Kit"); -} + expect(eq(mcpp::pack::render_closure_need( + {"@rpath/My Kit.framework/My Kit", K::Staged, "bin/My Kit"}), "needs\t@rpath/My Kit.framework/My Kit\tbin/My Kit")); +}}; -TEST(PackStageTree, AMissingTreeIsRefusedRatherThanDescribedAsEmpty) { +UTest a_missing_tree_is_refused_rather_than_described_as_empty{"PackStageTree/AMissingTreeIsRefusedRatherThanDescribedAsEmpty", [] { Tmp t; // An empty manifest for a directory that does not exist would say "nothing // is staged", which is what a correct pack of an empty bundle also says. // The two must not be spelled alike. - EXPECT_FALSE(mcpp::pack::write_stage_manifest(t.path / "never-staged")); - EXPECT_FALSE(std::filesystem::exists( - mcpp::pack::stage_manifest_path(t.path / "never-staged"))); -} + expect(!mcpp::pack::write_stage_manifest(t.path / "never-staged")); + expect(!(std::filesystem::exists( + mcpp::pack::stage_manifest_path(t.path / "never-staged")))); +}}; // ── #622 A5: the wasm32-emscripten stem family ─────────────────────────── // "The launcher is the executable; the family is what the link also wrote" — // asked of the directory, never of a fixed extension list. -TEST(PackStageTree, TheRequiredWasmModuleIsFound) { +UTest the_required_wasm_module_is_found{"PackStageTree/TheRequiredWasmModuleIsFound", [] { Tmp t; write_file(t.path / "app.js", "launcher"); write_file(t.path / "app.wasm", "\0asm"); auto fam = mcpp::pack::emscripten_stem_family(t.path, "app.js"); - EXPECT_TRUE(fam.hasWasm); - EXPECT_EQ(fam.siblings, (std::vector{"app.wasm"})); -} + expect(bool(fam.hasWasm)); + expect(eq(fam.siblings, (std::vector{"app.wasm"}))); +}}; // The negative direction: a launcher with no `.wasm` beside it (a build // directory that does not match the graph) must report the absence, not a // family that happens to be empty of the required member. -TEST(PackStageTree, AMissingWasmModuleIsReported) { +UTest a_missing_wasm_module_is_reported{"PackStageTree/AMissingWasmModuleIsReported", [] { Tmp t; write_file(t.path / "app.js", "launcher"); auto fam = mcpp::pack::emscripten_stem_family(t.path, "app.js"); - EXPECT_FALSE(fam.hasWasm); - EXPECT_TRUE(fam.siblings.empty()); -} + expect(!fam.hasWasm); + expect(fam.siblings.empty()); +}}; // Optional siblings travel when the link wrote them, and only files that // actually share the stem are picked up — a neighbouring, unrelated launcher // in the same directory (a second target) must not bleed into this one's // family. -TEST(PackStageTree, OptionalSiblingsTravelAndUnrelatedFilesDoNot) { +UTest optional_siblings_travel_and_unrelated_files_do_not{"PackStageTree/OptionalSiblingsTravelAndUnrelatedFilesDoNot", [] { Tmp t; write_file(t.path / "app.js", "launcher"); write_file(t.path / "app.wasm", "\0asm"); @@ -283,7 +281,7 @@ TEST(PackStageTree, OptionalSiblingsTravelAndUnrelatedFilesDoNot) { write_file(t.path / "other.js", "a different target's launcher"); write_file(t.path / "other.wasm", "\0asm"); auto fam = mcpp::pack::emscripten_stem_family(t.path, "app.js"); - EXPECT_TRUE(fam.hasWasm); - EXPECT_EQ(fam.siblings, (std::vector{ - "app.data", "app.wasm", "app.worker.js"})); -} + expect(bool(fam.hasWasm)); + expect(eq(fam.siblings, (std::vector{ + "app.data", "app.wasm", "app.worker.js"}))); +}}; diff --git a/tests/unit/test_patch.cpp b/tests/unit/test_patch.cpp index 7e08ccb20..da447378c 100644 --- a/tests/unit/test_patch.cpp +++ b/tests/unit/test_patch.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pm.patch; +using namespace mcpplibs::mtest; + // A dependency's patches (design 2026-11-02 part 3 §14.1). The applier is // exact: a hunk applies where its header says, every line of context and every // line it removes byte for byte, or the patch is refused naming the hunk. @@ -31,7 +33,7 @@ std::string get(const fs::path& p) { return ss.str(); } -TEST(Patch, AHunkAppliesWhereItsHeaderSays) { +UTest a_hunk_applies_where_its_header_says{"Patch/AHunkAppliesWhereItsHeaderSays", [] { auto root = fresh("apply"); put(root / "src/a.c", "one\ntwo\nthree\nfour\n"); const std::string diff = @@ -42,11 +44,11 @@ TEST(Patch, AHunkAppliesWhereItsHeaderSays) { "-three\n" "+THREE\n" "+three and a half\n"; - ASSERT_TRUE(pt::apply(root, diff, "p.patch")); - EXPECT_EQ(get(root / "src/a.c"), "one\ntwo\nTHREE\nthree and a half\nfour\n"); -} + expect(fatal(bool(pt::apply(root, diff, "p.patch")))); + expect(eq(get(root / "src/a.c"), "one\ntwo\nTHREE\nthree and a half\nfour\n")); +}}; -TEST(Patch, AHunkThatDoesNotMatchIsRefusedNamingIt) { +UTest a_hunk_that_does_not_match_is_refused_naming_it{"Patch/AHunkThatDoesNotMatchIsRefusedNamingIt", [] { auto root = fresh("refuse"); put(root / "src/a.c", "one\ntwo\nthree\n"); const std::string diff = @@ -56,32 +58,32 @@ TEST(Patch, AHunkThatDoesNotMatchIsRefusedNamingIt) { "-TWO\n" "+2\n"; auto r = pt::apply(root, diff, "p.patch"); - ASSERT_FALSE(r); - EXPECT_NE(r.error().find("hunk 1"), std::string::npos) << r.error(); - EXPECT_NE(r.error().find("line 2"), std::string::npos) << r.error(); - EXPECT_EQ(get(root / "src/a.c"), "one\ntwo\nthree\n"); // untouched -} + expect(fatal(!r)); + if (auto&& failed = expect(ne(r.error().find("hunk 1"), std::string::npos)); !failed) failed << r.error(); + if (auto&& failed = expect(ne(r.error().find("line 2"), std::string::npos)); !failed) failed << r.error(); + expect(eq(get(root / "src/a.c"), "one\ntwo\nthree\n")); // untouched +}}; -TEST(Patch, NothingIsSearchedForElsewhere) { +UTest nothing_is_searched_for_elsewhere{"Patch/NothingIsSearchedForElsewhere", [] { // The text the hunk expects is in the file, one line later than its // header says: an exact applier refuses rather than finding it. auto root = fresh("nofuzz"); put(root / "a.c", "zero\none\ntwo\n"); const std::string diff = "--- a/a.c\n+++ b/a.c\n@@ -1,1 +1,1 @@\n-one\n+ONE\n"; - EXPECT_FALSE(pt::apply(root, diff, "p.patch")); -} + expect(!pt::apply(root, diff, "p.patch")); +}}; -TEST(Patch, BytesAreKeptAsTheyAre) { +UTest bytes_are_kept_as_they_are{"Patch/BytesAreKeptAsTheyAre", [] { // CRLF lines stay CRLF, and a last line without a newline stays without. auto root = fresh("bytes"); put(root / "w.txt", "a\r\nb\r\nc"); const std::string diff = "--- a/w.txt\n+++ b/w.txt\n@@ -2,2 +2,2 @@\n b\r\n-c\n\\ No newline at end of file\n+C\n\\ No newline at end of file\n"; - ASSERT_TRUE(pt::apply(root, diff, "p.patch")); - EXPECT_EQ(get(root / "w.txt"), "a\r\nb\r\nC"); -} + expect(fatal(bool(pt::apply(root, diff, "p.patch")))); + expect(eq(get(root / "w.txt"), "a\r\nb\r\nC")); +}}; -TEST(Patch, FilesAreCreatedAndRemoved) { +UTest files_are_created_and_removed{"Patch/FilesAreCreatedAndRemoved", [] { auto root = fresh("create"); put(root / "old.c", "x\n"); const std::string diff = @@ -91,12 +93,12 @@ TEST(Patch, FilesAreCreatedAndRemoved) { "diff --git a/old.c b/old.c\n" "deleted file mode 100644\n" "--- a/old.c\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-x\n"; - ASSERT_TRUE(pt::apply(root, diff, "p.patch")); - EXPECT_EQ(get(root / "new.c"), "int n;\nint m;\n"); - EXPECT_FALSE(fs::exists(root / "old.c")); -} + expect(fatal(bool(pt::apply(root, diff, "p.patch")))); + expect(eq(get(root / "new.c"), "int n;\nint m;\n")); + expect(!fs::exists(root / "old.c")); +}}; -TEST(Patch, TheSetDigestIsTheContentsInOrder) { +UTest the_set_digest_is_the_contents_in_order{"Patch/TheSetDigestIsTheContentsInOrder", [] { auto d = fresh("digest"); put(d / "one.patch", "A"); put(d / "two.patch", "B"); @@ -104,36 +106,36 @@ TEST(Patch, TheSetDigestIsTheContentsInOrder) { auto ab = pt::set_digest({(d / "one.patch").string(), (d / "two.patch").string()}); auto ba = pt::set_digest({(d / "two.patch").string(), (d / "one.patch").string()}); auto ab2 = pt::set_digest({(d / "renamed.patch").string(), (d / "two.patch").string()}); - ASSERT_TRUE(ab && ba && ab2); - EXPECT_NE(*ab, *ba); - EXPECT_EQ(*ab, *ab2); - EXPECT_EQ(ab->size(), 64u); - EXPECT_EQ(pt::variant_dir_name("3.6.1", *ab), std::format("3.6.1+p{}", ab->substr(0, 12))); -} - -TEST(Patch, AVariantIsMadeOnceAndWhole) { + expect(fatal(ab && ba && ab2)); + expect(ne(*ab, *ba)); + expect(eq(*ab, *ab2)); + expect(eq(ab->size(), 64u)); + expect(eq(pt::variant_dir_name("3.6.1", *ab), std::format("3.6.1+p{}", ab->substr(0, 12)))); +}}; + +UTest a_variant_is_made_once_and_whole{"Patch/AVariantIsMadeOnceAndWhole", [] { auto d = fresh("variant"); put(d / "src/inc/m.h", "#define M 1\n"); put(d / "fix.patch", "--- a/inc/m.h\n+++ b/inc/m.h\n@@ -1 +1 @@\n-#define M 1\n+#define M 2\n"); auto digest = pt::set_digest({(d / "fix.patch").string()}); - ASSERT_TRUE(digest); + expect(fatal(bool(digest))); const auto variant = d / "variants" / pt::variant_dir_name("1.0", *digest); - ASSERT_TRUE(pt::materialize(d / "src", {(d / "fix.patch").string()}, *digest, variant)); - EXPECT_EQ(get(variant / "inc/m.h"), "#define M 2\n"); - EXPECT_EQ(get(d / "src/inc/m.h"), "#define M 1\n"); // the source is untouched + expect(fatal(bool(pt::materialize(d / "src", {(d / "fix.patch").string()}, *digest, variant)))); + expect(eq(get(variant / "inc/m.h"), "#define M 2\n")); + expect(eq(get(d / "src/inc/m.h"), "#define M 1\n")); // the source is untouched // A second call finds it made. put(variant / "inc/m.h", "#define M 3\n"); - ASSERT_TRUE(pt::materialize(d / "src", {(d / "fix.patch").string()}, *digest, variant)); - EXPECT_EQ(get(variant / "inc/m.h"), "#define M 3\n"); + expect(fatal(bool(pt::materialize(d / "src", {(d / "fix.patch").string()}, *digest, variant)))); + expect(eq(get(variant / "inc/m.h"), "#define M 3\n")); // A patch that does not apply leaves no variant. put(d / "bad.patch", "--- a/inc/m.h\n+++ b/inc/m.h\n@@ -1 +1 @@\n-#define M 9\n+#define M 2\n"); auto bad = pt::set_digest({(d / "bad.patch").string()}); const auto badVariant = d / "variants" / pt::variant_dir_name("1.0", *bad); - EXPECT_FALSE(pt::materialize(d / "src", {(d / "bad.patch").string()}, *bad, badVariant)); - EXPECT_FALSE(fs::exists(badVariant)); -} + expect(!(pt::materialize(d / "src", {(d / "bad.patch").string()}, *bad, badVariant))); + expect(!fs::exists(badVariant)); +}}; -TEST(Patch, AVariantNeverWritesThroughALink) { +UTest a_variant_never_writes_through_a_link{"Patch/AVariantNeverWritesThroughALink", [] { // A store may hold a version directory, or a file in it, as a link into // another store (a test's home linking the developer's payloads, measured: // the first version of this patched the developer's own zlib.h through @@ -143,19 +145,19 @@ TEST(Patch, AVariantNeverWritesThroughALink) { put(d / "real/inc/other.h", "#define O 1\n"); std::error_code ec; fs::create_directory_symlink(d / "real", d / "linked", ec); - if (ec) GTEST_SKIP() << "no symbolic links here: " << ec.message(); + if (ec) skip([&] { std::ostringstream text; text << "no symbolic links here: " << ec.message(); return text.str(); }()); fs::create_symlink(d / "real/inc/other.h", d / "real/inc/o-link.h", ec); put(d / "fix.patch", "--- a/inc/m.h\n+++ b/inc/m.h\n@@ -1 +1 @@\n-#define M 1\n+#define M 2\n" "--- a/inc/o-link.h\n+++ b/inc/o-link.h\n@@ -1 +1 @@\n-#define O 1\n+#define O 2\n"); auto digest = pt::set_digest({(d / "fix.patch").string()}); - ASSERT_TRUE(digest); + expect(fatal(bool(digest))); const auto variant = d / "variants" / pt::variant_dir_name("1.0", *digest); - ASSERT_TRUE(pt::materialize(d / "linked", {(d / "fix.patch").string()}, *digest, variant)); - EXPECT_EQ(get(variant / "inc/m.h"), "#define M 2\n"); - EXPECT_EQ(get(variant / "inc/o-link.h"), "#define O 2\n"); - EXPECT_FALSE(fs::is_symlink(variant)); - EXPECT_EQ(get(d / "real/inc/m.h"), "#define M 1\n"); - EXPECT_EQ(get(d / "real/inc/other.h"), "#define O 1\n"); -} + expect(fatal(bool(pt::materialize(d / "linked", {(d / "fix.patch").string()}, *digest, variant)))); + expect(eq(get(variant / "inc/m.h"), "#define M 2\n")); + expect(eq(get(variant / "inc/o-link.h"), "#define O 2\n")); + expect(!fs::is_symlink(variant)); + expect(eq(get(d / "real/inc/m.h"), "#define M 1\n")); + expect(eq(get(d / "real/inc/other.h"), "#define O 1\n")); +}}; } // namespace diff --git a/tests/unit/test_pe_exports.cpp b/tests/unit/test_pe_exports.cpp index e453eb8ca..2ba90d224 100644 --- a/tests/unit/test_pe_exports.cpp +++ b/tests/unit/test_pe_exports.cpp @@ -1,6 +1,6 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.pe_exports; import mcpp.build.coff_exports; import mcpp.build.plan; @@ -9,28 +9,30 @@ import mcpp.modgraph.graph; import mcpp.modgraph.scanner; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + using mcpp::build::pe::ir_declares_exports; using mcpp::build::pe::read_nm_exports; -TEST(PeExports, RecognizesBothBitcodeContainersByContents) { +UTest recognizes_both_bitcode_containers_by_contents{"PeExports/RecognizesBothBitcodeContainersByContents", [] { const std::array raw{std::byte{0x42}, std::byte{0x43}, std::byte{0xc0}, std::byte{0xde}}; const std::array wrapped{std::byte{0xde}, std::byte{0xc0}, std::byte{0x17}, std::byte{0x0b}}; const std::array coff{std::byte{0x64}, std::byte{0x86}, std::byte{1}, std::byte{0}}; - EXPECT_TRUE(mcpp::build::pe::is_bitcode(raw)); - EXPECT_TRUE(mcpp::build::pe::is_bitcode(wrapped)); - EXPECT_FALSE(mcpp::build::pe::is_bitcode(coff)); - EXPECT_FALSE(mcpp::build::pe::is_bitcode(std::span(raw).first(3))); -} + expect(mcpp::build::pe::is_bitcode(raw)); + expect(mcpp::build::pe::is_bitcode(wrapped)); + expect(!mcpp::build::pe::is_bitcode(coff)); + expect(!(mcpp::build::pe::is_bitcode(std::span(raw).first(3)))); +}}; -TEST(PeExports, DllStorageIntentCoversFunctionsDataAndAliases) { - EXPECT_TRUE(ir_declares_exports("define dso_local dllexport i32 @api() {\nret i32 7\n}")); - EXPECT_TRUE(ir_declares_exports("@api = dllexport global i32 7")); - EXPECT_TRUE(ir_declares_exports("@api = dllexport alias i32, ptr @other")); - EXPECT_TRUE(ir_declares_exports("@\"name with spaces\" = dso_local dllexport constant i32 7")); -} +UTest dll_storage_intent_covers_functions_data_and_aliases{"PeExports/DllStorageIntentCoversFunctionsDataAndAliases", [] { + expect(bool(ir_declares_exports("define dso_local dllexport i32 @api() {\nret i32 7\n}"))); + expect(bool(ir_declares_exports("@api = dllexport global i32 7"))); + expect(bool(ir_declares_exports("@api = dllexport alias i32, ptr @other"))); + expect(bool(ir_declares_exports("@\"name with spaces\" = dso_local dllexport constant i32 7"))); +}}; -TEST(PeExports, NamesCommentsAttributesAndDataDoNotDeclareExports) { - EXPECT_FALSE(ir_declares_exports(R"( +UTest names_comments_attributes_and_data_do_not_declare_exports{"PeExports/NamesCommentsAttributesAndDataDoNotDeclareExports", [] { + expect(!(ir_declares_exports(R"( ; define dllexport i32 @comment() @dllexport = global i32 1 @"dllexport" = global i32 2 @@ -42,22 +44,22 @@ define i32 @dllexport() { attributes #0 = { "dllexport"="/EXPORT:pretend" } !llvm.ident = !{!1} !1 = !{!"/EXPORT:not_a_linker_option"} -)")); -} +)"))); +}}; -TEST(PeExports, LinkerMetadataIntentFollowsOnlyItsNamedRoot) { - EXPECT_TRUE(ir_declares_exports(R"( +UTest linker_metadata_intent_follows_only_its_named_root{"PeExports/LinkerMetadataIntentFollowsOnlyItsNamedRoot", [] { + expect(bool(ir_declares_exports(R"( !llvm.linker.options = !{!7, !8} !7 = !{!"/DEFAULTLIB:libcmt"} !8 = !{!"\2DEXPORT:chosen"} -)")); - EXPECT_TRUE(ir_declares_exports("!llvm.linker.options = !{!\"/ExPoRt:chosen,DATA\"}")); - EXPECT_TRUE(ir_declares_exports("module asm \".section .drectve\\0A.ascii \\22 /EXPORT:chosen\\22\"")); - EXPECT_FALSE(ir_declares_exports("!llvm.linker.options = !{!1}\n!1 = !{!\"/DEFAULTLIB:x\"}\n!2 = !{!\"/EXPORT:unused\"}")); - EXPECT_FALSE(ir_declares_exports("!llvm.linker.options = !{!1}\n!1 = !{!1}")); -} +)"))); + expect(bool(ir_declares_exports("!llvm.linker.options = !{!\"/ExPoRt:chosen,DATA\"}"))); + expect(bool(ir_declares_exports("module asm \".section .drectve\\0A.ascii \\22 /EXPORT:chosen\\22\""))); + expect(!ir_declares_exports("!llvm.linker.options = !{!1}\n!1 = !{!\"/DEFAULTLIB:x\"}\n!2 = !{!\"/EXPORT:unused\"}")); + expect(!ir_declares_exports("!llvm.linker.options = !{!1}\n!1 = !{!1}")); +}}; -TEST(PeExports, NmCandidatesKeepDataAndComdatButExcludeNonDefinitions) { +UTest nm_candidates_keep_data_and_comdat_but_exclude_non_definitions{"PeExports/NmCandidatesKeepDataAndComdatButExcludeNonDefinitions", [] { auto symbols = read_nm_exports(R"( function T ---------------- 0 data D ---------------- 0 @@ -71,30 +73,30 @@ absolute A ---------------- 0 ??_Gwidget@@ T ---------------- 0 managed.name T ---------------- 0 )", false); - ASSERT_TRUE(symbols.has_value()) << symbols.error(); + if (auto&& failed = expect(fatal(symbols.has_value())); !failed) failed << symbols.error(); const std::vector expected{ {"function", false}, {"data", true}, {"zero", true}, {"constant", true}, {"comdat", false}, {"vtable", true}}; - EXPECT_EQ(*symbols, expected); -} + expect(eq(*symbols, expected)); +}}; -TEST(PeExports, I386KeepsStdcallDecorationButNormalizesCdecl) { +UTest i386_keeps_stdcall_decoration_but_normalizes_cdecl{"PeExports/I386KeepsStdcallDecorationButNormalizesCdecl", [] { auto symbols = read_nm_exports("_cdecl T ---------------- 0\n_stdcall@4 T ---------------- 0\n_variable D ---------------- 0\n", true); - ASSERT_TRUE(symbols.has_value()); + expect(fatal(symbols.has_value())); const std::vector expected{ {"cdecl", false}, {"_stdcall@4", false}, {"variable", true}}; - EXPECT_EQ(*symbols, expected); + expect(eq(*symbols, expected)); auto amd64 = read_nm_exports("_cdecl T ---------------- 0", false); - ASSERT_TRUE(amd64.has_value()); - EXPECT_EQ(amd64->front().name, "_cdecl"); -} + expect(fatal(amd64.has_value())); + expect(eq(amd64->front().name, "_cdecl")); +}}; -TEST(PeExports, UnexpectedNmOutputIsAnErrorRatherThanAnEmptySurface) { - EXPECT_FALSE(read_nm_exports("tool emitted an unexpected diagnostic", false)); - EXPECT_FALSE(read_nm_exports("candidate T", false)); -} +UTest unexpected_nm_output_is_an_error_rather_than_an_empty_surface{"PeExports/UnexpectedNmOutputIsAnErrorRatherThanAnEmptySurface", [] { + expect(!read_nm_exports("tool emitted an unexpected diagnostic", false)); + expect(!read_nm_exports("candidate T", false)); +}}; -TEST(PeExports, OptOutAffectsOnlyTheMsvcAbiSharedLinkForm) { +UTest opt_out_affects_only_the_msvc_abi_shared_link_form{"PeExports/OptOutAffectsOnlyTheMsvcAbiSharedLinkForm", [] { for (auto triple : {"x86_64-pc-windows-msvc", "i686-pc-windows-msvc", "x86_64-w64-mingw32", "x86_64-linux-gnu", "aarch64-apple-darwin"}) { for (bool enabled : {false, true}) { @@ -112,43 +114,42 @@ TEST(PeExports, OptOutAffectsOnlyTheMsvcAbiSharedLinkForm) { root.manifest = manifest; const auto plan = mcpp::build::make_plan(manifest, tc, {}, {}, {}, {root}, root.root, root.root / "target", {}, {}); - ASSERT_TRUE(plan.has_value()) << plan.error(); - ASSERT_EQ(plan->linkUnits.size(), 1u); - EXPECT_EQ(!plan->linkUnits.front().defFile.empty(), - enabled && std::string_view(triple).ends_with("windows-msvc")) << triple; + if (auto&& failed = expect(fatal(plan.has_value())); !failed) failed << plan.error(); + expect(fatal(eq(plan->linkUnits.size(), 1u))); + expect(eq(!plan->linkUnits.front().defFile.empty(), enabled && std::string_view(triple).ends_with("windows-msvc"))) << triple; } } -} +}}; -TEST(PeExports, SymbolPatternsFollowVersionScriptGlobs) { +UTest symbol_patterns_follow_version_script_globs{"PeExports/SymbolPatternsFollowVersionScriptGlobs", [] { using mcpp::build::pe::symbol_matches; - EXPECT_TRUE(symbol_matches("vk_icd*", "vk_icdGetInstanceProcAddr")); - EXPECT_TRUE(symbol_matches("vk_icd*", "vk_icd")); - EXPECT_FALSE(symbol_matches("vk_icd*", "vkGetInstanceProcAddr")); - EXPECT_TRUE(symbol_matches("api_?", "api_x")); - EXPECT_FALSE(symbol_matches("api_?", "api_xy")); - EXPECT_TRUE(symbol_matches("api_[a-c]*", "api_b_open")); - EXPECT_FALSE(symbol_matches("api_[!a-c]*", "api_b_open")); - EXPECT_TRUE(symbol_matches("*Plugin*", "?createPlugin@@YAPEAXXZ")); - EXPECT_TRUE(symbol_matches("exact", "exact")); - EXPECT_FALSE(symbol_matches("exact", "exactly")); - EXPECT_TRUE(symbol_matches("*", "")); - EXPECT_TRUE(symbol_matches("a[b", "a[b")); // an unterminated class is a character -} + expect(symbol_matches("vk_icd*", "vk_icdGetInstanceProcAddr")); + expect(symbol_matches("vk_icd*", "vk_icd")); + expect(!symbol_matches("vk_icd*", "vkGetInstanceProcAddr")); + expect(symbol_matches("api_?", "api_x")); + expect(!symbol_matches("api_?", "api_xy")); + expect(symbol_matches("api_[a-c]*", "api_b_open")); + expect(!symbol_matches("api_[!a-c]*", "api_b_open")); + expect(symbol_matches("*Plugin*", "?createPlugin@@YAPEAXXZ")); + expect(symbol_matches("exact", "exact")); + expect(!symbol_matches("exact", "exactly")); + expect(symbol_matches("*", "")); + expect(symbol_matches("a[b", "a[b")); // an unterminated class is a character +}}; -TEST(PeExports, NarrowKeepsTheMatchingCandidatesAndTheirDataFlag) { +UTest narrow_keeps_the_matching_candidates_and_their_data_flag{"PeExports/NarrowKeepsTheMatchingCandidatesAndTheirDataFlag", [] { std::vector all{ {"plugin_open", false}, {"plugin_table", true}, {"helper", false}}; const std::vector patterns{"plugin_*"}; auto kept = mcpp::build::pe::narrow(all, patterns); - ASSERT_EQ(kept.size(), 2u); - EXPECT_EQ(kept[0].name, "plugin_open"); - EXPECT_FALSE(kept[0].data); - EXPECT_EQ(kept[1].name, "plugin_table"); - EXPECT_TRUE(kept[1].data); -} + expect(fatal(eq(kept.size(), 2u))); + expect(eq(kept[0].name, "plugin_open")); + expect(!(kept[0].data)); + expect(eq(kept[1].name, "plugin_table")); + expect(bool(kept[1].data)); +}}; -TEST(PeExports, ExportsWithoutDiscoveryIsRefusedOnlyOnMsvcAbiRows) { +UTest exports_without_discovery_is_refused_only_on_msvc_abi_rows{"PeExports/ExportsWithoutDiscoveryIsRefusedOnlyOnMsvcAbiRows", [] { for (auto triple : {"x86_64-pc-windows-msvc", "x86_64-w64-mingw32", "x86_64-linux-gnu"}) { mcpp::manifest::Manifest manifest; manifest.package.name = "probe"; @@ -166,10 +167,10 @@ TEST(PeExports, ExportsWithoutDiscoveryIsRefusedOnlyOnMsvcAbiRows) { const auto plan = mcpp::build::make_plan(manifest, tc, {}, {}, {}, {root}, root.root, root.root / "target", {}, {}); if (std::string_view(triple).ends_with("windows-msvc")) { - ASSERT_FALSE(plan.has_value()) << triple; - EXPECT_NE(plan.error().find("windows_auto_export = false"), std::string::npos); + expect(fatal(!plan.has_value())) << triple; + expect(ne(plan.error().find("windows_auto_export = false"), std::string::npos)); } else { - EXPECT_TRUE(plan.has_value()) << triple << ": " << (plan ? "" : plan.error()); + if (auto&& failed = expect(plan.has_value()); !failed) failed << triple << ": " << (plan ? "" : plan.error()); } } -} +}}; diff --git a/tests/unit/test_platform.cpp b/tests/unit/test_platform.cpp index 4bf6dcd0b..3960ec22a 100644 --- a/tests/unit/test_platform.cpp +++ b/tests/unit/test_platform.cpp @@ -1,11 +1,13 @@ -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.platform; import mcpp.build.prepare_inputs; import mcpp.toolchain.triple; +using namespace mcpplibs::mtest; + // SPEC-013 (design 2026-10-28): what a platform states, how it is read in its // three places, which platform a graph selects, how the tiers merge, and the // two templates of the link (artifact names, seal steps). Tables, so each rule @@ -20,7 +22,7 @@ namespace { mm::Manifest parse(std::string_view text) { auto m = mm::parse_string(text); - if (!m) ADD_FAILURE() << m.error().format(); + if (!m) expect(false) << m.error().format(); return m ? *m : mm::Manifest{}; } @@ -81,41 +83,41 @@ openkal-musl = "0.22.3" // ── The reader ────────────────────────────────────────────────────────────── -TEST(PlatformManifest, EveryGroupIsRead) { +UTest every_group_is_read{"PlatformManifest/EveryGroupIsRead", [] { auto m = parse(kPlatform); - ASSERT_TRUE(m.platformDeclared); + expect(fatal(bool(m.platformDeclared))); auto const& p = m.platform; - EXPECT_EQ(p.targets.value, (std::vector{"x86_64-openkal", "aarch64-openkal"})); - EXPECT_EQ(p.toolchain.value, "llvm@23.1.3"); - EXPECT_EQ(p.runner.cmd, (std::vector{"okrun", "{}"})); - EXPECT_EQ(p.runner.host, "same-arch"); - EXPECT_EQ(p.carriers.at(0).package.spec.shortName, "openkal-run"); - EXPECT_EQ(p.carriers.at(0).section, ".oke"); - EXPECT_TRUE(p.carriers.at(0).main.ends_with("carrier/main.cpp")); - ASSERT_TRUE(p.layers.contains("c++-abi")); - EXPECT_EQ(p.layers.at("c++-abi").ref.spec.version, "0.16.3"); - EXPECT_EQ(p.layers.at("c-abi").ref.spec.shortName, "openkal-musl"); - EXPECT_FALSE(p.layers.at("c-abi").ref.spec.defaultFeatures); - EXPECT_EQ(p.layers.at("c-abi").ref.spec.features, (std::vector{"posix"})); - EXPECT_TRUE(p.layers.at("kernel-abi").ref.off); - EXPECT_EQ(p.tools.at("okld").ref.spec.shortName, "openkal-tools"); - EXPECT_EQ(p.tools.at("probe").ref.program, "probe-rs"); - EXPECT_EQ(p.build.cxxflags, (std::vector{"-fPIC"})); - ASSERT_EQ(p.buildCfg.size(), 1u); - EXPECT_EQ(p.link.driver.value, "lld"); - EXPECT_EQ(p.link.start.value, "ok_main"); - ASSERT_EQ(p.link.inputs.size(), 2u); - EXPECT_EQ(p.link.inputs[0].as, "ok-notes.S"); - EXPECT_TRUE(p.link.inputs[1].file.ends_with("crt/x.o")); - EXPECT_EQ(p.link.seal.size(), 2u); - EXPECT_EQ(p.hosted.toolchain.value, "llvm@23.1.3"); - EXPECT_TRUE(p.hosted.layers.contains("c-abi")); - EXPECT_EQ(p.versions.at("openkal-musl"), "0.22.3"); - EXPECT_EQ(p.runner.origin.tier, mp::Tier::Platform); - EXPECT_GT(p.runner.origin.line, 0); -} - -TEST(PlatformManifest, RefusalsNameTheKey) { + expect(eq(p.targets.value, (std::vector{"x86_64-openkal", "aarch64-openkal"}))); + expect(eq(p.toolchain.value, "llvm@23.1.3")); + expect(eq(p.runner.cmd, (std::vector{"okrun", "{}"}))); + expect(eq(p.runner.host, "same-arch")); + expect(eq(p.carriers.at(0).package.spec.shortName, "openkal-run")); + expect(eq(p.carriers.at(0).section, ".oke")); + expect(p.carriers.at(0).main.ends_with("carrier/main.cpp")); + expect(fatal(p.layers.contains("c++-abi"))); + expect(eq(p.layers.at("c++-abi").ref.spec.version, "0.16.3")); + expect(eq(p.layers.at("c-abi").ref.spec.shortName, "openkal-musl")); + expect(!(p.layers.at("c-abi").ref.spec.defaultFeatures)); + expect(eq(p.layers.at("c-abi").ref.spec.features, (std::vector{"posix"}))); + expect(bool(p.layers.at("kernel-abi").ref.off)); + expect(eq(p.tools.at("okld").ref.spec.shortName, "openkal-tools")); + expect(eq(p.tools.at("probe").ref.program, "probe-rs")); + expect(eq(p.build.cxxflags, (std::vector{"-fPIC"}))); + expect(fatal(eq(p.buildCfg.size(), 1u))); + expect(eq(p.link.driver.value, "lld")); + expect(eq(p.link.start.value, "ok_main")); + expect(fatal(eq(p.link.inputs.size(), 2u))); + expect(eq(p.link.inputs[0].as, "ok-notes.S")); + expect(p.link.inputs[1].file.ends_with("crt/x.o")); + expect(eq(p.link.seal.size(), 2u)); + expect(eq(p.hosted.toolchain.value, "llvm@23.1.3")); + expect(p.hosted.layers.contains("c-abi")); + expect(eq(p.versions.at("openkal-musl"), "0.22.3")); + expect(eq(p.runner.origin.tier, mp::Tier::Platform)); + expect(gt(p.runner.origin.line, 0)); +}}; + +UTest refusals_name_the_key{"PlatformManifest/RefusalsNameTheKey", [] { const std::string head = "[package]\nname = \"p\"\nversion = \"1.0.0\"\n"; struct Row { std::string body; std::string_view says; }; const Row rows[] = { @@ -150,13 +152,13 @@ TEST(PlatformManifest, RefusalsNameTheKey) { }; for (auto const& r : rows) { auto e = parse_error(head + r.body); - EXPECT_NE(e.find(r.says), std::string::npos) << r.body << "\n=> " << e; + expect(ne(e.find(r.says), std::string::npos)) << r.body << "\n=> " << e; } -} +}}; // An unknown key, in `[platform]`, its tables or `[host]`, is a warning: a // manifest written for a later mcpp still reads (SPEC-013 §18.1). -TEST(PlatformManifest, UnknownKeysWarn) { +UTest unknown_keys_warn{"PlatformManifest/UnknownKeysWarn", [] { auto m = parse(R"( [package] name = "p" @@ -182,11 +184,11 @@ runner = ["x"] "[platform.link] has unsupported key 'later'", "[platform.build] has unsupported key 'rustflags'", "[host] has unsupported key 'runner'" }) - EXPECT_NE(all.find(key), std::string::npos) << key << "\n=> " << all; - EXPECT_EQ(m.platform.link.exe.value, "{name}.oke"); -} + expect(ne(all.find(key), std::string::npos)) << key << "\n=> " << all; + expect(eq(m.platform.link.exe.value, "{name}.oke")); +}}; -TEST(PlatformManifest, OverridesAndHostAreRead) { +UTest overrides_and_host_are_read{"PlatformManifest/OverridesAndHostAreRead", [] { auto m = parse(R"( [package] name = "app" @@ -207,23 +209,23 @@ flags = ["--build-id"] [target.x86_64-openkal] platform = "none" )"); - for (auto const& w : m.schemaWarnings) ADD_FAILURE() << "unexpected warning: " << w; - EXPECT_TRUE(m.hostPlatform.platformSet); - EXPECT_EQ(m.hostPlatform.platform.spec.shortName, "openkal-platform"); - ASSERT_EQ(m.platformOverrides.size(), 2u); + for (auto const& w : m.schemaWarnings) expect(false) << "unexpected warning: " << w; + expect(bool(m.hostPlatform.platformSet)); + expect(eq(m.hostPlatform.platform.spec.shortName, "openkal-platform")); + expect(fatal(eq(m.platformOverrides.size(), 2u))); auto const& o = m.platformOverrides[0]; - EXPECT_EQ(o.fields.runner.cmd.front(), "my-okrun"); - EXPECT_TRUE(o.fields.runner.exclusive); - EXPECT_TRUE(o.fields.layers.at("c-abi").ref.spec.path.ends_with("openkal-musl")); - EXPECT_TRUE(o.fields.tools.at("okld").ref.spec.path.ends_with("openkal-image/tools")); - EXPECT_EQ(o.fields.link.flags.value, (std::vector{"--build-id"})); - EXPECT_EQ(o.fields.runner.origin.tier, mp::Tier::Manifest); - EXPECT_TRUE(m.platformOverrides[1].platformNone); + expect(eq(o.fields.runner.cmd.front(), "my-okrun")); + expect(bool(o.fields.runner.exclusive)); + expect(o.fields.layers.at("c-abi").ref.spec.path.ends_with("openkal-musl")); + expect(o.fields.tools.at("okld").ref.spec.path.ends_with("openkal-image/tools")); + expect(eq(o.fields.link.flags.value, (std::vector{"--build-id"}))); + expect(eq(o.fields.runner.origin.tier, mp::Tier::Manifest)); + expect(bool(m.platformOverrides[1].platformNone)); // The runner's command is the row's template as well. - EXPECT_EQ(m.targetOverrides.at("cfg(platform = \"openkal\")").runner.front(), "my-okrun"); -} + expect(eq(m.targetOverrides.at("cfg(platform = \"openkal\")").runner.front(), "my-okrun")); +}}; -TEST(PlatformManifest, ReferencesInEveryForm) { +UTest references_in_every_form{"PlatformManifest/ReferencesInEveryForm", [] { struct Row { std::string_view text; std::string_view ns, name, version; }; const Row rows[] = { { "openkal-platform@0.17.1", "mcpplibs", "openkal-platform", "0.17.1" }, @@ -232,20 +234,20 @@ TEST(PlatformManifest, ReferencesInEveryForm) { }; for (auto const& r : rows) { auto ref = mp::parse_ref_text(r.text); - ASSERT_TRUE(ref) << r.text; - EXPECT_EQ(ref->spec.namespace_, r.ns); - EXPECT_EQ(ref->spec.shortName, r.name); - EXPECT_EQ(ref->spec.version, r.version); + expect(fatal(bool(ref))) << r.text; + expect(eq(ref->spec.namespace_, r.ns)); + expect(eq(ref->spec.shortName, r.name)); + expect(eq(ref->spec.version, r.version)); } - EXPECT_FALSE(mp::parse_ref_text("openkal-platform")); - EXPECT_FALSE(mp::parse_ref_text("@1.0")); - EXPECT_EQ(mp::platform_name("openkal-platform"), "openkal"); - EXPECT_EQ(mp::platform_name("stm32-bsp"), "stm32-bsp"); -} + expect(!mp::parse_ref_text("openkal-platform")); + expect(!mp::parse_ref_text("@1.0")); + expect(eq(mp::platform_name("openkal-platform"), "openkal")); + expect(eq(mp::platform_name("stm32-bsp"), "stm32-bsp")); +}}; // ── Selection: the first tier that answers decides ───────────────────────── -TEST(PlatformSelect, TheTiersInOrder) { +UTest the_tiers_in_order{"PlatformSelect/TheTiersInOrder", [] { auto m = parse(R"( [package] name = "app" @@ -270,22 +272,21 @@ platform = "machine-platform@1.0.0" : std::span{}, column }; auto s = pf::select_target(in, t); - EXPECT_TRUE(s) << (s ? "" : s.error()); + if (auto&& failed = expect(bool(s)); !failed) failed << (s ? "" : s.error()); return s ? (s->none ? std::string("none") : s->ref.spec.shortName) + "|" + std::string(mp::tier_name(s->origin.tier)) : std::string("error"); }; - EXPECT_EQ(pick("flag-platform@1.0.0", "env-platform@1.0.0", true, true, "row-platform@1.0.0"), - "flag-platform|flag"); - EXPECT_EQ(pick("", "env-platform@1.0.0", true, true, "row-platform@1.0.0"), "env-platform|env"); - EXPECT_EQ(pick("", "", true, true, "row-platform@1.0.0"), "project-platform|mcpp.toml"); - EXPECT_EQ(pick("", "", false, true, "row-platform@1.0.0"), "machine-platform|mcpp.toml"); - EXPECT_EQ(pick("", "", false, false, "row-platform@1.0.0"), "row-platform|built-in"); - EXPECT_EQ(pick("", "", false, false, ""), "none|built-in"); - EXPECT_EQ(pick("none", "", true, true, "row-platform@1.0.0"), "none|flag"); -} - -TEST(PlatformSelect, TheMostSpecificSelectorDecides) { + expect(eq(pick("flag-platform@1.0.0", "env-platform@1.0.0", true, true, "row-platform@1.0.0"), "flag-platform|flag")); + expect(eq(pick("", "env-platform@1.0.0", true, true, "row-platform@1.0.0"), "env-platform|env")); + expect(eq(pick("", "", true, true, "row-platform@1.0.0"), "project-platform|mcpp.toml")); + expect(eq(pick("", "", false, true, "row-platform@1.0.0"), "machine-platform|mcpp.toml")); + expect(eq(pick("", "", false, false, "row-platform@1.0.0"), "row-platform|built-in")); + expect(eq(pick("", "", false, false, ""), "none|built-in")); + expect(eq(pick("none", "", true, true, "row-platform@1.0.0"), "none|flag")); +}}; + +UTest the_most_specific_selector_decides{"PlatformSelect/TheMostSpecificSelectorDecides", [] { auto m = parse(R"( [package] name = "app" @@ -297,45 +298,45 @@ platform = "arch@1.0.0" )"); pf::ChoiceInputs in{ "", "", "", m.platformOverrides, {}, "" }; auto s = pf::select_target(in, matcher("x86_64-openkal")); - ASSERT_TRUE(s); - EXPECT_EQ(s->ref.spec.shortName, "triple"); + expect(fatal(bool(s))); + expect(eq(s->ref.spec.shortName, "triple")); auto a = pf::select_target(in, matcher("aarch64-openkal")); - ASSERT_TRUE(a); - EXPECT_TRUE(a->none); -} + expect(fatal(bool(a))); + expect(bool(a->none)); +}}; -TEST(PlatformSelect, TheHostGraph) { +UTest the_host_graph{"PlatformSelect/TheHostGraph", [] { mp::Override project, config; config.platformSet = true; config.platform = *mp::parse_ref_text("machine@1.0.0"); auto s = pf::select_host("", false, project, config); - ASSERT_TRUE(s); - EXPECT_EQ(s->ref.spec.shortName, "machine"); + expect(fatal(bool(s))); + expect(eq(s->ref.spec.shortName, "machine")); project.platformSet = true; project.platformNone = true; s = pf::select_host("", false, project, config); - ASSERT_TRUE(s); - EXPECT_TRUE(s->none); + expect(fatal(bool(s))); + expect(bool(s->none)); s = pf::select_host("flag@2.0.0", true, project, config); - ASSERT_TRUE(s); - EXPECT_EQ(s->ref.spec.shortName, "flag"); - EXPECT_EQ(s->origin.tier, mp::Tier::Flag); -} + expect(fatal(bool(s))); + expect(eq(s->ref.spec.shortName, "flag")); + expect(eq(s->origin.tier, mp::Tier::Flag)); +}}; -TEST(PlatformSelect, ADirectoryIsAPath) { +UTest a_directory_is_a_path{"PlatformSelect/ADirectoryIsAPath", [] { auto s = pf::parse_choice("path:/opt/board", {}); - ASSERT_TRUE(s); + expect(fatal(bool(s))); // Absolute on this machine: `D:/opt/board` on a Windows one. - EXPECT_TRUE(s->ref.spec.path.ends_with("/opt/board")) << s->ref.spec.path; + expect(s->ref.spec.path.ends_with("/opt/board")) << s->ref.spec.path; s = pf::parse_choice("../board", {}); - ASSERT_TRUE(s); - EXPECT_FALSE(s->ref.spec.path.empty()); - EXPECT_FALSE(pf::parse_choice("not-a-reference", {})); -} + expect(fatal(bool(s))); + expect(!(s->ref.spec.path.empty())); + expect(!pf::parse_choice("not-a-reference", {})); +}}; // ── Merge: per field, flags appended, sequences replaced ─────────────────── -TEST(PlatformMerge, TheRulesOfSection45) { +UTest the_rules_of_section45{"PlatformMerge/TheRulesOfSection45", [] { auto base = parse(kPlatform).platform; mp::Statement over; over.runner.cmd = {"my-okrun", "{}"}; @@ -346,85 +347,81 @@ TEST(PlatformMerge, TheRulesOfSection45) { over.link.sealSet = true; over.layers["c-abi"] = { mp::PackageRef{ .off = true }, {} }; pf::merge(base, over); - EXPECT_EQ(base.runner.cmd.front(), "my-okrun"); - EXPECT_EQ(base.runner.host, "same-arch"); - EXPECT_EQ(base.build.cxxflags, (std::vector{"-fPIC", "-DUSER"})); - EXPECT_EQ(base.link.flags.value, (std::vector{"-shared", "--build-id"})); - ASSERT_EQ(base.link.seal.size(), 1u); - EXPECT_EQ(base.link.seal.front().front(), "sign"); - EXPECT_EQ(base.link.inputs.size(), 2u); // not stated: kept - EXPECT_TRUE(base.layers.at("c-abi").ref.off); - EXPECT_FALSE(base.layers.at("c++-abi").ref.off); - EXPECT_EQ(base.link.start.value, "ok_main"); -} - -TEST(PlatformMerge, AHostTripleTakesTheHostedPartOnly) { + expect(eq(base.runner.cmd.front(), "my-okrun")); + expect(eq(base.runner.host, "same-arch")); + expect(eq(base.build.cxxflags, (std::vector{"-fPIC", "-DUSER"}))); + expect(eq(base.link.flags.value, (std::vector{"-shared", "--build-id"}))); + expect(fatal(eq(base.link.seal.size(), 1u))); + expect(eq(base.link.seal.front().front(), "sign")); + expect(eq(base.link.inputs.size(), 2u)); // not stated: kept + expect(bool(base.layers.at("c-abi").ref.off)); + expect(!(base.layers.at("c++-abi").ref.off)); + expect(eq(base.link.start.value, "ok_main")); +}}; + +UTest a_host_triple_takes_the_hosted_part_only{"PlatformMerge/AHostTripleTakesTheHostedPartOnly", [] { auto p = parse(kPlatform).platform; auto h = pf::hosted_view(p); - EXPECT_EQ(h.toolchain.value, "llvm@23.1.3"); - EXPECT_TRUE(h.layers.contains("c-abi")); - EXPECT_FALSE(h.layers.contains("c++-abi")); - EXPECT_FALSE(h.runner.set); - EXPECT_FALSE(h.link.driver.set); - EXPECT_TRUE(h.build.empty()); + expect(eq(h.toolchain.value, "llvm@23.1.3")); + expect(h.layers.contains("c-abi")); + expect(!h.layers.contains("c++-abi")); + expect(!h.runner.set); + expect(!h.link.driver.set); + expect(h.build.empty()); auto served = pf::serves(p, "board-platform", *mcpp::toolchain::triple::parse("x86_64-linux-gnu")); - ASSERT_TRUE(served); - EXPECT_TRUE(*served); + expect(fatal(bool(served))); + expect(bool(*served)); auto target = pf::serves(p, "board-platform", *mcpp::toolchain::triple::parse("x86_64-openkal")); - ASSERT_TRUE(target); - EXPECT_FALSE(*target); + expect(fatal(bool(target))); + expect(!(*target)); p.hosted = {}; auto refused = pf::serves(p, "board-platform", *mcpp::toolchain::triple::parse("x86_64-linux-gnu")); - ASSERT_FALSE(refused); - EXPECT_NE(refused.error().find("[platform.hosted]"), std::string::npos); -} + expect(fatal(!refused)); + expect(ne(refused.error().find("[platform.hosted]"), std::string::npos)); +}}; -TEST(PlatformMerge, CfgTablesFollowTheTarget) { +UTest cfg_tables_follow_the_target{"PlatformMerge/CfgTablesFollowTheTarget", [] { auto p = parse(kPlatform).platform; auto x = pf::evaluate_flags(p, matcher("x86_64-openkal")); auto a = pf::evaluate_flags(p, matcher("aarch64-openkal")); - EXPECT_EQ(x.cxxflags, (std::vector{"-fPIC"})); - EXPECT_EQ(a.cxxflags, (std::vector{"-fPIC", "-ffixed-x18"})); -} + expect(eq(x.cxxflags, (std::vector{"-fPIC"}))); + expect(eq(a.cxxflags, (std::vector{"-fPIC", "-ffixed-x18"}))); +}}; -TEST(PlatformMerge, CfgPlatformMatchesTheNameOrThePackage) { - EXPECT_TRUE(cfgpred::matches("cfg(platform = \"openkal\")", +UTest cfg_platform_matches_the_name_or_the_package{"PlatformMerge/CfgPlatformMatchesTheNameOrThePackage", [] { + expect(cfgpred::matches("cfg(platform = \"openkal\")", cfgpred::context_for("x86_64-openkal", "openkal-platform"))); - EXPECT_TRUE(cfgpred::matches("cfg(platform = \"openkal-platform\")", + expect(cfgpred::matches("cfg(platform = \"openkal-platform\")", cfgpred::context_for("x86_64-openkal", "openkal-platform"))); - EXPECT_FALSE(cfgpred::matches("cfg(platform = \"openkal\")", - cfgpred::context_for("x86_64-openkal"))); - EXPECT_GT(mcpp::manifest::cfg::specificity("cfg(os = \"openkal\")"), - mcpp::manifest::cfg::specificity("cfg(platform = \"openkal\")")); -} + expect(!(cfgpred::matches("cfg(platform = \"openkal\")", + cfgpred::context_for("x86_64-openkal")))); + expect(gt(mcpp::manifest::cfg::specificity("cfg(os = \"openkal\")"), mcpp::manifest::cfg::specificity("cfg(platform = \"openkal\")"))); +}}; // ── The link's templates ──────────────────────────────────────────────────── -TEST(PlatformLink, NamePatterns) { +UTest name_patterns{"PlatformLink/NamePatterns", [] { auto e = pf::split_name_pattern("{name}.oke"); - ASSERT_TRUE(e); - EXPECT_EQ(e->first, ""); - EXPECT_EQ(e->second, ".oke"); + expect(fatal(bool(e))); + expect(eq(e->first, "")); + expect(eq(e->second, ".oke")); auto s = pf::split_name_pattern("lib{name}.okso"); - ASSERT_TRUE(s); - EXPECT_EQ(s->first, "lib"); - EXPECT_EQ(s->second, ".okso"); - EXPECT_FALSE(pf::split_name_pattern("name.oke")); - EXPECT_FALSE(pf::split_name_pattern("{name}{name}")); - EXPECT_FALSE(pf::split_name_pattern("bin/{name}")); -} - -TEST(PlatformLink, SealSteps) { + expect(fatal(bool(s))); + expect(eq(s->first, "lib")); + expect(eq(s->second, ".okso")); + expect(!pf::split_name_pattern("name.oke")); + expect(!pf::split_name_pattern("{name}{name}")); + expect(!pf::split_name_pattern("bin/{name}")); +}}; + +UTest seal_steps{"PlatformLink/SealSteps", [] { const std::vector check{"okld", "check", "{}", "--with", "{needed}"}; const std::vector two{"a.okso", "b.okso"}; - EXPECT_EQ(pf::expand_step(check, "x.oke.tmp", two), - (std::vector{"okld", "check", "x.oke.tmp", "--with", "a.okso", - "--with", "b.okso"})); - EXPECT_EQ(pf::expand_step(check, "x.oke.tmp", {}), - (std::vector{"okld", "check", "x.oke.tmp"})); - EXPECT_EQ(pf::expand_step({"sign", "--image={}", "{needed}"}, "x", two), - (std::vector{"sign", "--image=x", "a.okso", "b.okso"})); -} + expect(eq(pf::expand_step(check, "x.oke.tmp", two), (std::vector{"okld", "check", "x.oke.tmp", "--with", "a.okso", + "--with", "b.okso"}))); + expect(eq(pf::expand_step(check, "x.oke.tmp", {}), (std::vector{"okld", "check", "x.oke.tmp"}))); + expect(eq(pf::expand_step({"sign", "--image={}", "{needed}"}, "x", two), (std::vector{"sign", "--image=x", "a.okso", "b.okso"}))); +}}; // ── The run (§10) ─────────────────────────────────────────────────────────── @@ -432,7 +429,7 @@ TEST(PlatformLink, SealSteps) { // this one when they agree; the execution host, with that architecture's // program, when one is named; refused otherwise. `--no-runner` and every // other runner are this machine's. -TEST(PlatformRun, TheRunnerReachesTheArtifactsArchitecture) { +UTest the_runner_reaches_the_artifacts_architecture{"PlatformRun/TheRunnerReachesTheArtifactsArchitecture", [] { using R = pf::RunnerReach; mp::Runner same; same.set = true; @@ -457,14 +454,14 @@ TEST(PlatformRun, TheRunnerReachesTheArtifactsArchitecture) { { &none, "aarch64", true, false, R::Here }, }; for (auto const& r : rows) - EXPECT_EQ(pf::runner_reach(*r.runner, r.target, "x86_64", r.executionHost, r.noRunner), r.want) + expect(eq(pf::runner_reach(*r.runner, r.target, "x86_64", r.executionHost, r.noRunner), r.want)) << r.runner->host << " " << r.target << " executionHost=" << r.executionHost << " noRunner=" << r.noRunner; -} +}}; // ── The report ────────────────────────────────────────────────────────────── -TEST(PlatformReport, SourcesAndJson) { +UTest sources_and_json{"PlatformReport/SourcesAndJson", [] { pf::Resolved r; r.active = true; r.package = "mcpplibs.openkal-platform"; @@ -473,24 +470,24 @@ TEST(PlatformReport, SourcesAndJson) { r.selection.origin = { mp::Tier::BuiltIn, {}, 0, "the target table" }; r.layers["c-abi"] = { "openkal-musl@0.22.3", mp::Tier::Platform }; r.layers["c++-abi"] = { "acme-runtime@1.0.0", mp::Tier::Graph }; - EXPECT_EQ(r.display(), "openkal-platform 0.17.1"); - EXPECT_EQ(r.identity(), "mcpplibs.openkal-platform@0.17.1"); + expect(eq(r.display(), "openkal-platform 0.17.1")); + expect(eq(r.identity(), "mcpplibs.openkal-platform@0.17.1")); auto rows = pf::report_rows(r); - ASSERT_FALSE(rows.empty()); + expect(fatal(!rows.empty())); bool graph = false; for (auto const& [k, v] : rows) if (k == "c++-abi") graph = v.find("(graph)") != std::string::npos; - EXPECT_TRUE(graph); + expect(bool(graph)); auto json = pf::to_json(r); - EXPECT_NE(json.find("\"name\":\"mcpplibs.openkal-platform\""), std::string::npos); - EXPECT_NE(json.find("\"source\":\"built-in\""), std::string::npos); - EXPECT_NE(json.find("\"layers.c++-abi\""), std::string::npos); - EXPECT_EQ(pf::to_json(pf::Resolved{}), "null"); -} + expect(ne(json.find("\"name\":\"mcpplibs.openkal-platform\""), std::string::npos)); + expect(ne(json.find("\"source\":\"built-in\""), std::string::npos)); + expect(ne(json.find("\"layers.c++-abi\""), std::string::npos)); + expect(eq(pf::to_json(pf::Resolved{}), "null")); +}}; // ── Runner hosts, carrier entries, descriptor keys (design 2026-10-29) ────── -TEST(PlatformManifest, RunnerHostsCarrierEntriesAndDescriptorKeysAreRead) { +UTest runner_hosts_carrier_entries_and_descriptor_keys_are_read{"PlatformManifest/RunnerHostsCarrierEntriesAndDescriptorKeysAreRead", [] { auto m = parse(R"( [package] name = "p" @@ -507,26 +504,26 @@ carrier = [ descriptor_keys = ["openkal", "linux"] )"); auto const& p = m.platform; - EXPECT_TRUE(p.runner.hostsSet); - EXPECT_EQ(p.runner.hosts, (std::vector{"linux-x86_64", "macos-aarch64"})); + expect(bool(p.runner.hostsSet)); + expect(eq(p.runner.hosts, (std::vector{"linux-x86_64", "macos-aarch64"}))); // The entry of a model this mcpp does not build is skipped, not refused. - ASSERT_EQ(p.carriers.size(), 2u); - EXPECT_EQ(p.carriers[0].hosts, (std::vector{"linux-x86_64"})); - EXPECT_EQ(p.carriers[0].build, "row"); - EXPECT_EQ(p.descriptorKeys.value, (std::vector{"openkal", "linux"})); + expect(fatal(eq(p.carriers.size(), 2u))); + expect(eq(p.carriers[0].hosts, (std::vector{"linux-x86_64"}))); + expect(eq(p.carriers[0].build, "row")); + expect(eq(p.descriptorKeys.value, (std::vector{"openkal", "linux"}))); // The entry naming the pair serves it; another pair takes the open entry. - EXPECT_EQ(mp::carrier_for(p, "linux-x86_64"), &p.carriers[0]); - EXPECT_EQ(mp::carrier_for(p, "windows-x86_64"), &p.carriers[1]); -} + expect(eq(mp::carrier_for(p, "linux-x86_64"), &p.carriers[0])); + expect(eq(mp::carrier_for(p, "windows-x86_64"), &p.carriers[1])); +}}; -TEST(PlatformManifest, ACarrierWithoutHostsServesEveryPairAndNoneServesNothing) { +UTest a_carrier_without_hosts_serves_every_pair_and_none_serves_nothing{"PlatformManifest/ACarrierWithoutHostsServesEveryPairAndNoneServesNothing", [] { auto m = parse(kPlatform); - EXPECT_EQ(mp::carrier_for(m.platform, "macos-aarch64"), &m.platform.carriers.at(0)); + expect(eq(mp::carrier_for(m.platform, "macos-aarch64"), &m.platform.carriers.at(0))); mp::Statement none; - EXPECT_EQ(mp::carrier_for(none, "linux-x86_64"), nullptr); -} + expect(eq(mp::carrier_for(none, "linux-x86_64"), nullptr)); +}}; -TEST(PlatformManifest, HostPairsAndDescriptorKeysAreAClosedVocabulary) { +UTest host_pairs_and_descriptor_keys_are_a_closed_vocabulary{"PlatformManifest/HostPairsAndDescriptorKeysAreAClosedVocabulary", [] { const auto base = std::string(R"( [package] name = "p" @@ -534,14 +531,14 @@ version = "1.0.0" [platform] targets = ["x86_64-openkal"] )"); - EXPECT_NE(parse_error(base + R"(runner = { cmd = ["okrun"], hosts = ["linux"] })" "\n"), ""); - EXPECT_NE(parse_error(base + R"(runner = { cmd = ["okrun"], hosts = ["freebsd-x86_64"] })" "\n"), ""); - EXPECT_NE(parse_error(base + R"(descriptor_keys = ["macos"])" "\n").find("macosx"), std::string::npos); - EXPECT_NE(parse_error(base + R"(descriptor_keys = [])" "\n"), ""); - EXPECT_EQ(parse_error(base + R"(descriptor_keys = ["openkal", "linux"])" "\n"), ""); -} - -TEST(PlatformMerge, AnOverrideReplacesHostsCarriersAndDescriptorKeys) { + expect(ne(parse_error(base + R"(runner = { cmd = ["okrun"], hosts = ["linux"] })" "\n"), "")); + expect(ne(parse_error(base + R"(runner = { cmd = ["okrun"], hosts = ["freebsd-x86_64"] })" "\n"), "")); + expect(ne(parse_error(base + R"(descriptor_keys = ["macos"])" "\n").find("macosx"), std::string::npos)); + expect(ne(parse_error(base + R"(descriptor_keys = [])" "\n"), "")); + expect(eq(parse_error(base + R"(descriptor_keys = ["openkal", "linux"])" "\n"), "")); +}}; + +UTest an_override_replaces_hosts_carriers_and_descriptor_keys{"PlatformMerge/AnOverrideReplacesHostsCarriersAndDescriptorKeys", [] { auto m = parse(kPlatform); mp::Statement over; over.runner.hosts = {"windows-x86_64"}; @@ -553,27 +550,27 @@ TEST(PlatformMerge, AnOverrideReplacesHostsCarriersAndDescriptorKeys) { over.carriers = {c}; auto merged = m.platform; pf::merge(merged, over); - EXPECT_EQ(merged.runner.hosts, (std::vector{"windows-x86_64"})); - EXPECT_EQ(merged.runner.cmd, m.platform.runner.cmd); - ASSERT_EQ(merged.carriers.size(), 1u); - EXPECT_EQ(merged.carriers[0].section, ".x"); - EXPECT_EQ(merged.descriptorKeys.value, (std::vector{"openkal"})); -} + expect(eq(merged.runner.hosts, (std::vector{"windows-x86_64"}))); + expect(eq(merged.runner.cmd, m.platform.runner.cmd)); + expect(fatal(eq(merged.carriers.size(), 1u))); + expect(eq(merged.carriers[0].section, ".x")); + expect(eq(merged.descriptorKeys.value, (std::vector{"openkal"}))); +}}; // D20 (design 2026-11-07 §4.10): the target table's default is one release; a // failed build names a newer one in the index with the line to write, and says // nothing when the project, the configuration or the command line chose. -TEST(PlatformDefault, ANewerReleaseIsNamedOnlyForTheDefault) { +UTest a_newer_release_is_named_only_for_the_default{"PlatformDefault/ANewerReleaseIsNamedOnlyForTheDefault", [] { namespace mp = mcpp::manifest::platform; mp::Origin builtIn{}; builtIn.tier = mp::Tier::BuiltIn; auto tip = mcpp::platform::newer_release_tip(builtIn, "openkal-platform", "0.24.3", "0.24.4", "2026.11.7.2"); - ASSERT_TRUE(tip.has_value()); - EXPECT_NE(tip->find("openkal-platform 0.24.3, mcpp 2026.11.7.2's default"), std::string::npos) << *tip; - EXPECT_NE(tip->find("platform = \"openkal-platform@0.24.4\""), std::string::npos) << *tip; + expect(fatal(tip.has_value())); + if (auto&& failed = expect(ne(tip->find("openkal-platform 0.24.3, mcpp 2026.11.7.2's default"), std::string::npos)); !failed) failed << *tip; + if (auto&& failed = expect(ne(tip->find("platform = \"openkal-platform@0.24.4\""), std::string::npos)); !failed) failed << *tip; for (auto tier : {mp::Tier::Manifest, mp::Tier::Config, mp::Tier::Env, mp::Tier::Flag}) { mp::Origin chosen{}; chosen.tier = tier; - EXPECT_FALSE(mcpp::platform::newer_release_tip(chosen, "openkal-platform", "0.24.3", "0.24.4", "x")); + expect(!mcpp::platform::newer_release_tip(chosen, "openkal-platform", "0.24.3", "0.24.4", "x")); } - EXPECT_FALSE(mcpp::platform::newer_release_tip(builtIn, "openkal-platform", "0.24.3", "0.24.3", "x")); - EXPECT_FALSE(mcpp::platform::newer_release_tip(builtIn, "openkal-platform", "0.24.3", "", "x")); -} + expect(!mcpp::platform::newer_release_tip(builtIn, "openkal-platform", "0.24.3", "0.24.3", "x")); + expect(!mcpp::platform::newer_release_tip(builtIn, "openkal-platform", "0.24.3", "", "x")); +}}; diff --git a/tests/unit/test_platform_apple_sdk.cpp b/tests/unit/test_platform_apple_sdk.cpp index 7933dbb9c..246a60763 100644 --- a/tests/unit/test_platform_apple_sdk.cpp +++ b/tests/unit/test_platform_apple_sdk.cpp @@ -1,11 +1,13 @@ -#include #include import std; +import mcpplibs.mtest; import mcpp.invocation; import mcpplibs.os.platform; import mcpp.toolchain.apple_sdk; +using namespace mcpplibs::mtest; + namespace mac = mcpp::toolchain::apple; namespace { @@ -33,7 +35,7 @@ class ScopedDeploymentTargetEnv { // it, and they are pure, which is why a Linux runner can check them: the // probing itself needs the machine. -TEST(AppleSdk, EveryRequestedSdkNamesItsOwnDirectories) { +UTest every_requested_sdk_names_its_own_directories{"AppleSdk/EveryRequestedSdkNamesItsOwnDirectories", [] { struct Case { std::string_view sdk, platformDir, sdkDir; bool clt; }; const Case cases[] = { { mac::sdk_macos, "MacOSX.platform", "MacOSX.sdk", true }, @@ -42,28 +44,28 @@ TEST(AppleSdk, EveryRequestedSdkNamesItsOwnDirectories) { }; for (auto const& c : cases) { auto l = mac::sdk_layout(c.sdk); - ASSERT_TRUE(l.has_value()) << c.sdk; - EXPECT_EQ(l->platformDir, c.platformDir) << c.sdk; - EXPECT_EQ(l->sdkDir, c.sdkDir) << c.sdk; + expect(fatal(l.has_value())) << c.sdk; + expect(eq(l->platformDir, c.platformDir)) << c.sdk; + expect(eq(l->sdkDir, c.sdkDir)) << c.sdk; // A CLT-ONLY INSTALL SHIPS NO iOS SDK. Probing its SDKs directory for // one would be a path that cannot exist, and the refusal that names // the SDK is the better answer -- so the table says so. - EXPECT_EQ(l->inCommandLineTools, c.clt) << c.sdk; + expect(eq(l->inCommandLineTools, c.clt)) << c.sdk; } // A name no Apple SDK answers to resolves to nothing, and `sdk_path` // returns nullopt before it runs a single command. for (auto bogus : {"", "ios", "iphone", "macos", "watchos"}) { - EXPECT_FALSE(mac::sdk_layout(bogus).has_value()) << bogus; - EXPECT_FALSE(mac::sdk_path(bogus).has_value()) << bogus; + expect(!(mac::sdk_layout(bogus).has_value())) << bogus; + expect(!(mac::sdk_path(bogus).has_value())) << bogus; } -} +}}; // SDKROOT NAMES ONE SDK AND THERE ARE THREE QUESTIONS. Honouring the override // for every request would answer `sdk_path("iphoneos")` with a macOS SDK on // any machine whose shell has SDKROOT set -- a wrong sysroot, which surfaces // much later as missing headers rather than as a bad override. So the override // is matched against what the path IS, and this is that comparison. -TEST(AppleSdk, AnOverrideAnswersOnlyForTheSdkItIs) { +UTest an_override_answers_only_for_the_sdk_it_is{"AppleSdk/AnOverrideAnswersOnlyForTheSdkItIs", [] { struct Case { std::string_view path, name; }; const Case cases[] = { { "/x/MacOSX.sdk", mac::sdk_macos }, @@ -74,15 +76,14 @@ TEST(AppleSdk, AnOverrideAnswersOnlyForTheSdkItIs) { { "/x/iPhoneSimulator18.4.sdk", mac::sdk_iphonesim }, }; for (auto const& c : cases) - EXPECT_EQ(mac::sdk_name_of_root(std::filesystem::path(c.path)), c.name) + expect(eq(mac::sdk_name_of_root(std::filesystem::path(c.path)), c.name)) << c.path; // AND THE TWO iOS NAMES DO NOT COLLAPSE INTO EACH OTHER. Both begin // `iPhone`, and a prefix test would make the simulator SDK satisfy a // device request -- an SDK that compiles and produces an artefact for the // wrong platform, which no later step refuses. - EXPECT_NE(mac::sdk_name_of_root("/x/iPhoneSimulator18.4.sdk"), - mac::sdk_name_of_root("/x/iPhoneOS18.4.sdk")); + expect(ne(mac::sdk_name_of_root("/x/iPhoneSimulator18.4.sdk"), mac::sdk_name_of_root("/x/iPhoneOS18.4.sdk"))); // AND `.sdk` IS REQUIRED. This is positive identification, and the suffix // is the marker Apple's own naming gives an SDK; `iPhoneOS.platform` is @@ -90,33 +91,33 @@ TEST(AppleSdk, AnOverrideAnswersOnlyForTheSdkItIs) { for (auto notAnSdk : {"/x/Developer", "/x/MacOSX", "/x/iPhoneOS", "/x/iPhoneOS.platform", "/x/.sdk", "/x/Frameworks.sdk"}) - EXPECT_TRUE(mac::sdk_name_of_root(notAnSdk).empty()) << notAnSdk; -} + expect(mac::sdk_name_of_root(notAnSdk).empty()) << notAnSdk; +}}; // The override arm itself, which on macOS sits behind a filesystem probe and // is therefore reachable by no other check on a Linux runner. -TEST(AppleSdk, AnUnnamedOverrideAnswersTheDefaultQuestionOnly) { +UTest an_unnamed_override_answers_the_default_question_only{"AppleSdk/AnUnnamedOverrideAnswersTheDefaultQuestionOnly", [] { // Positive identification: an SDK answers for itself and for nothing else. - EXPECT_TRUE (mac::sdkroot_answers("/x/iPhoneOS18.4.sdk", mac::sdk_iphoneos)); - EXPECT_FALSE(mac::sdkroot_answers("/x/iPhoneOS18.4.sdk", mac::sdk_iphonesim)); - EXPECT_FALSE(mac::sdkroot_answers("/x/iPhoneOS18.4.sdk", mac::sdk_macos)); - EXPECT_TRUE (mac::sdkroot_answers("/x/MacOSX15.4.sdk", mac::sdk_macos)); - EXPECT_FALSE(mac::sdkroot_answers("/x/MacOSX15.4.sdk", mac::sdk_iphoneos)); + expect(bool(mac::sdkroot_answers("/x/iPhoneOS18.4.sdk", mac::sdk_iphoneos))); + expect(!mac::sdkroot_answers("/x/iPhoneOS18.4.sdk", mac::sdk_iphonesim)); + expect(!mac::sdkroot_answers("/x/iPhoneOS18.4.sdk", mac::sdk_macos)); + expect(bool(mac::sdkroot_answers("/x/MacOSX15.4.sdk", mac::sdk_macos))); + expect(!mac::sdkroot_answers("/x/MacOSX15.4.sdk", mac::sdk_iphoneos)); // NO FLAG DAY. `SDKROOT=/opt/my-sysroot` is a spelling clang accepts and // a spelling `sdk_path()` honoured before it took a parameter, so it // still answers the default question -- and still does not answer an iOS // one, because a hand-rolled macOS sysroot is not an iOS SDK. for (auto unnamed : {"/opt/my-sysroot", "/x/Developer", "/x/MacOSX"}) { - EXPECT_TRUE (mac::sdkroot_answers(unnamed, mac::sdk_macos)) << unnamed; - EXPECT_FALSE(mac::sdkroot_answers(unnamed, mac::sdk_iphoneos)) << unnamed; - EXPECT_FALSE(mac::sdkroot_answers(unnamed, mac::sdk_iphonesim)) << unnamed; + expect(bool(mac::sdkroot_answers(unnamed, mac::sdk_macos))) << unnamed; + expect(!mac::sdkroot_answers(unnamed, mac::sdk_iphoneos)) << unnamed; + expect(!mac::sdkroot_answers(unnamed, mac::sdk_iphonesim)) << unnamed; } // A name no Apple SDK answers to is not a question, so no override // answers it. - EXPECT_FALSE(mac::sdkroot_answers("/x/MacOSX15.4.sdk", "watchos")); -} + expect(!mac::sdkroot_answers("/x/MacOSX15.4.sdk", "watchos")); +}}; // NO FLAG DAY FOR THE THREE CALLERS. The parameter has a default, and the // default request behaves exactly as the unparameterised function did: on a @@ -124,15 +125,15 @@ TEST(AppleSdk, AnUnnamedOverrideAnswersTheDefaultQuestionOnly) { // `xcrun --show-sdk-path` probe stays first for it and is not used for any // other SDK, because it returns the ACTIVE default rather than the one asked // for. -TEST(AppleSdk, TheDefaultRequestIsTheMacosSdk) { - EXPECT_EQ(mac::sdk_path(), mac::sdk_path(mac::sdk_macos)); +UTest the_default_request_is_the_macos_sdk{"AppleSdk/TheDefaultRequestIsTheMacosSdk", [] { + expect(eq(mac::sdk_path(), mac::sdk_path(mac::sdk_macos))); if constexpr (!mcpplibs::os::is_macos) { - EXPECT_FALSE(mac::sdk_path().has_value()) + expect(!(mac::sdk_path().has_value())) << "an Apple SDK on a host that has none is a located path that " "does not exist"; - EXPECT_FALSE(mac::sdk_path(mac::sdk_iphoneos).has_value()); + expect(!(mac::sdk_path(mac::sdk_iphoneos).has_value())); } -} +}}; // ─── deployment_target: a TARGET decision, not a HOST one (mcpp#685) ──────── // @@ -146,43 +147,43 @@ TEST(AppleSdk, TheDefaultRequestIsTheMacosSdk) { // guard would make `TargetIsMacosEnvBeatsManifestBeatsDefault` fail on any // non-Apple host. -TEST(AppleSdk, DeploymentTargetIsEmptyForANonMacosTarget) { +UTest deployment_target_is_empty_for_a_non_macos_target{"AppleSdk/DeploymentTargetIsEmptyForANonMacosTarget", [] { // No env, no manifest value, and no env with a manifest value: a // non-macOS target gets no floor regardless of what either input says, // because applying one would be an answer to a question this target // never asked (see min_platform_version's Apple-only branch). ScopedDeploymentTargetEnv noEnv(nullptr); - EXPECT_EQ(mac::deployment_target(false, ""), ""); - EXPECT_EQ(mac::deployment_target(false, "11.0"), ""); + expect(eq(mac::deployment_target(false, ""), "")); + expect(eq(mac::deployment_target(false, "11.0"), "")); ScopedDeploymentTargetEnv env("12.3"); - EXPECT_EQ(mac::deployment_target(false, "11.0"), ""); -} + expect(eq(mac::deployment_target(false, "11.0"), "")); +}}; -TEST(AppleSdk, TargetIsMacosEnvBeatsManifestBeatsDefault) { +UTest target_is_macos_env_beats_manifest_beats_default{"AppleSdk/TargetIsMacosEnvBeatsManifestBeatsDefault", [] { { // Neither input set: the built-in floor. ScopedDeploymentTargetEnv noEnv(nullptr); - EXPECT_EQ(mac::deployment_target(true, ""), mac::default_deployment_target); + expect(eq(mac::deployment_target(true, ""), mac::default_deployment_target)); } { // Manifest only: the project's stated default. ScopedDeploymentTargetEnv noEnv(nullptr); - EXPECT_EQ(mac::deployment_target(true, "11.0"), "11.0"); + expect(eq(mac::deployment_target(true, "11.0"), "11.0")); } { // Env only: the per-invocation override, same convention cargo/cc use. ScopedDeploymentTargetEnv env("12.3"); - EXPECT_EQ(mac::deployment_target(true, ""), "12.3"); + expect(eq(mac::deployment_target(true, ""), "12.3")); } { // Both set: env wins. ScopedDeploymentTargetEnv env("12.3"); - EXPECT_EQ(mac::deployment_target(true, "11.0"), "12.3"); + expect(eq(mac::deployment_target(true, "11.0"), "12.3")); } { // An env var present but empty is the same as absent (the convention // every other MACOSX_DEPLOYMENT_TARGET reader in this tree follows). ScopedDeploymentTargetEnv env(""); - EXPECT_EQ(mac::deployment_target(true, "11.0"), "11.0"); + expect(eq(mac::deployment_target(true, "11.0"), "11.0")); } -} +}}; diff --git a/tests/unit/test_platform_env.cpp b/tests/unit/test_platform_env.cpp index 7822fc7e0..575a3180d 100644 --- a/tests/unit/test_platform_env.cpp +++ b/tests/unit/test_platform_env.cpp @@ -1,10 +1,12 @@ -#include #include import std; +import mcpplibs.mtest; import mcpp.invocation; import mcpp.runtime.launch_env; +using namespace mcpplibs::mtest; + namespace { class ScopedVar { @@ -35,18 +37,17 @@ std::string host(std::string_view p) { } // namespace -TEST(PlatformEnv, StripPrivateGlibcDropsOnlyPayloadEntries) { +UTest strip_private_glibc_drops_only_payload_entries{"PlatformEnv/StripPrivateGlibcDropsOnlyPayloadEntries", [] { auto in = join({"/usr/lib", "/home/u/.mcpp/registry/data/xpkgs/xim-x-glibc/2.39/lib64", "/opt/mine"}); - EXPECT_EQ(mcpp::runtime::launch_env::strip_private_glibc(in), - join({host("/usr/lib"), host("/opt/mine")})); -} + expect(eq(mcpp::runtime::launch_env::strip_private_glibc(in), join({host("/usr/lib"), host("/opt/mine")}))); +}}; -TEST(PlatformEnv, StripPrivateGlibcCanEmptyTheList) { - EXPECT_EQ(mcpp::runtime::launch_env::strip_private_glibc( - "/x/xpkgs/xim-x-glibc/2.42/lib64"), ""); - EXPECT_EQ(mcpp::runtime::launch_env::strip_private_glibc(""), ""); -} +UTest strip_private_glibc_can_empty_the_list{"PlatformEnv/StripPrivateGlibcCanEmptyTheList", [] { + expect(eq(mcpp::runtime::launch_env::strip_private_glibc( + "/x/xpkgs/xim-x-glibc/2.42/lib64"), "")); + expect(eq(mcpp::runtime::launch_env::strip_private_glibc(""), "")); +}}; // The regression this guards (mcpp#311 investigation): the composed value // becomes an EXPLICIT override, and process.cppm's merged_environ takes explicit @@ -55,42 +56,38 @@ TEST(PlatformEnv, StripPrivateGlibcCanEmptyTheList) { // glibc, it reached the child anyway and a payload tool patched against a // DIFFERENT glibc segfaulted in the dynamic linker. Sanitizing here is what // makes process.cppm's guarantee actually hold one hop down. -TEST(PlatformEnv, PrependPathListSanitizesTheInheritedLoaderTail) { +UTest prepend_path_list_sanitizes_the_inherited_loader_tail{"PlatformEnv/PrependPathListSanitizesTheInheritedLoaderTail", [] { auto inherited = join({"/first", "/home/u/.mcpp/registry/data/xpkgs/xim-x-glibc/2.39/lib64", "/last"}); ScopedVar ld("LD_LIBRARY_PATH", inherited.c_str()); std::vector dirs{"/payload/lib"}; - EXPECT_EQ(mcpp::runtime::launch_env::prepend_path_list("LD_LIBRARY_PATH", dirs), - join({host("/payload/lib"), host("/first"), host("/last")})); -} + expect(eq(mcpp::runtime::launch_env::prepend_path_list("LD_LIBRARY_PATH", dirs), join({host("/payload/lib"), host("/first"), host("/last")}))); +}}; // A payload dir the CALLER passed is the entry the sandbox binary needs — it // must survive even though it matches the same pattern. -TEST(PlatformEnv, PrependPathListKeepsExplicitPayloadDirs) { +UTest prepend_path_list_keeps_explicit_payload_dirs{"PlatformEnv/PrependPathListKeepsExplicitPayloadDirs", [] { ScopedVar ld("LD_LIBRARY_PATH", "/home/u/.mcpp/registry/data/xpkgs/xim-x-glibc/2.39/lib64"); std::vector dirs{ "/home/u/.mcpp/registry/data/xpkgs/xim-x-glibc/2.42/lib64"}; // Explicit dir kept, inherited (older, mismatched) entry dropped. - EXPECT_EQ(mcpp::runtime::launch_env::prepend_path_list("LD_LIBRARY_PATH", dirs), - host("/home/u/.mcpp/registry/data/xpkgs/xim-x-glibc/2.42/lib64")); -} + expect(eq(mcpp::runtime::launch_env::prepend_path_list("LD_LIBRARY_PATH", dirs), host("/home/u/.mcpp/registry/data/xpkgs/xim-x-glibc/2.42/lib64"))); +}}; // PATH is not a loader search path: leave its inherited value alone. -TEST(PlatformEnv, PrependPathListLeavesPathUntouched) { +UTest prepend_path_list_leaves_path_untouched{"PlatformEnv/PrependPathListLeavesPathUntouched", [] { auto inherited = join({"/bin", "/x/xpkgs/xim-x-glibc/2.39/lib64"}); ScopedVar path("PATH", inherited.c_str()); std::vector dirs{"/tools/bin"}; - EXPECT_EQ(mcpp::runtime::launch_env::prepend_path_list("PATH", dirs), - join({host("/tools/bin"), "/bin", "/x/xpkgs/xim-x-glibc/2.39/lib64"})); -} + expect(eq(mcpp::runtime::launch_env::prepend_path_list("PATH", dirs), join({host("/tools/bin"), "/bin", "/x/xpkgs/xim-x-glibc/2.39/lib64"}))); +}}; -TEST(PlatformEnv, PrependPathListWithNoInheritedValueIsJustTheDirs) { +UTest prepend_path_list_with_no_inherited_value_is_just_the_dirs{"PlatformEnv/PrependPathListWithNoInheritedValueIsJustTheDirs", [] { ScopedVar ld("LD_LIBRARY_PATH", nullptr); std::vector dirs{"/a", "/b"}; - EXPECT_EQ(mcpp::runtime::launch_env::prepend_path_list("LD_LIBRARY_PATH", dirs), - join({host("/a"), host("/b")})); -} + expect(eq(mcpp::runtime::launch_env::prepend_path_list("LD_LIBRARY_PATH", dirs), join({host("/a"), host("/b")}))); +}}; diff --git a/tests/unit/test_platform_fs.cpp b/tests/unit/test_platform_fs.cpp index 14386994a..b02916c22 100644 --- a/tests/unit/test_platform_fs.cpp +++ b/tests/unit/test_platform_fs.cpp @@ -1,11 +1,13 @@ -#include import std; +import mcpplibs.mtest; import mcpplibs.os.platform; import mcpplibs.os.fs; import mcpp.invocation; import mcpp.home.files; +using namespace mcpplibs::mtest; + namespace { struct TempDir { @@ -31,7 +33,7 @@ std::string read_file(const std::filesystem::path& path) { return {std::istreambuf_iterator(in), {}}; } -TEST(PlatformFs, ReplaceFileAtomicallyReplacesExistingFile) { +UTest replace_file_atomically_replaces_existing_file{"PlatformFs/ReplaceFileAtomicallyReplacesExistingFile", [] { TempDir temp; auto source = temp.path / "new.tmp"; auto destination = temp.path / "compile_commands.json"; @@ -39,23 +41,23 @@ TEST(PlatformFs, ReplaceFileAtomicallyReplacesExistingFile) { write_file(destination, "old"); std::error_code ec; - EXPECT_TRUE(mcpp::home::replace_file(source, destination, ec)) + expect(bool(mcpp::home::replace_file(source, destination, ec))) << ec.message(); - EXPECT_FALSE(std::filesystem::exists(source)); - EXPECT_EQ(read_file(destination), "new"); -} + expect(!std::filesystem::exists(source)); + expect(eq(read_file(destination), "new")); +}}; -TEST(PlatformFs, ReplaceFailureKeepsExistingDestination) { +UTest replace_failure_keeps_existing_destination{"PlatformFs/ReplaceFailureKeepsExistingDestination", [] { TempDir temp; auto destination = temp.path / "compile_commands.json"; write_file(destination, "last-known-good"); std::error_code ec; - EXPECT_FALSE(mcpp::home::replace_file( + expect(!mcpp::home::replace_file( temp.path / "missing.tmp", destination, ec)); - EXPECT_TRUE(ec); - EXPECT_EQ(read_file(destination), "last-known-good"); -} + expect(bool(ec)); + expect(eq(read_file(destination), "last-known-good")); +}}; } // namespace @@ -63,69 +65,65 @@ TEST(PlatformFs, ReplaceFailureKeepsExistingDestination) { // Outside Windows there is no path limit of this kind, so the helper is the // identity: a relative path stays relative and nothing is spelled differently. -TEST(PlatformFs, ExtendedLengthIsTheIdentityOutsideWindows) { +UTest extended_length_is_the_identity_outside_windows{"PlatformFs/ExtendedLengthIsTheIdentityOutsideWindows", [] { if constexpr (mcpplibs::os::is_windows) { - GTEST_SKIP() << "the Windows branch is covered by the spelling tests"; + skip("the Windows branch is covered by the spelling tests"); } else { const std::filesystem::path rel{"obj/pkg/__pkg/dep/a.cpp.ddi"}; - EXPECT_EQ(mcpplibs::os::fs::extended(rel), rel); + expect(eq(mcpplibs::os::fs::extended(rel), rel)); const std::filesystem::path abs{"/tmp/x/../y"}; - EXPECT_EQ(mcpplibs::os::fs::extended(abs), abs); - EXPECT_TRUE(mcpplibs::os::fs::extended({}).empty()); + expect(eq(mcpplibs::os::fs::extended(abs), abs)); + expect(mcpplibs::os::fs::extended({}).empty()); } -} +}}; // The Windows rule, as a pure function, so every host tests it. -TEST(PlatformFs, WindowsExtendedLengthSpellsADrivePath) { +UTest windows_extended_length_spells_a_drive_path{"PlatformFs/WindowsExtendedLengthSpellsADrivePath", [] { using mcpplibs::os::fs::windows_extended; - EXPECT_EQ(mcpplibs::os::fs::windows_extended("C:/Users/runner/.mcpp/obj/a.ddi"), - "\\\\?\\C:\\Users\\runner\\.mcpp\\obj\\a.ddi"); + expect(eq(mcpplibs::os::fs::windows_extended("C:/Users/runner/.mcpp/obj/a.ddi"), "\\\\?\\C:\\Users\\runner\\.mcpp\\obj\\a.ddi")); // `.` and `..` are resolved lexically, because the prefix turns their // processing off; `..` does not climb above the drive. - EXPECT_EQ(mcpplibs::os::fs::windows_extended("C:\\a\\.\\b\\..\\c\\"), - "\\\\?\\C:\\a\\c"); - EXPECT_EQ(mcpplibs::os::fs::windows_extended("C:/../../x"), "\\\\?\\C:\\x"); - EXPECT_EQ(mcpplibs::os::fs::windows_extended("D:/"), "\\\\?\\D:\\"); -} + expect(eq(mcpplibs::os::fs::windows_extended("C:\\a\\.\\b\\..\\c\\"), "\\\\?\\C:\\a\\c")); + expect(eq(mcpplibs::os::fs::windows_extended("C:/../../x"), "\\\\?\\C:\\x")); + expect(eq(mcpplibs::os::fs::windows_extended("D:/"), "\\\\?\\D:\\")); +}}; -TEST(PlatformFs, WindowsExtendedLengthSpellsAUncPath) { +UTest windows_extended_length_spells_a_unc_path{"PlatformFs/WindowsExtendedLengthSpellsAUncPath", [] { using mcpplibs::os::fs::windows_extended; - EXPECT_EQ(mcpplibs::os::fs::windows_extended("//server/share/dir/../f.o"), - "\\\\?\\UNC\\server\\share\\f.o"); + expect(eq(mcpplibs::os::fs::windows_extended("//server/share/dir/../f.o"), "\\\\?\\UNC\\server\\share\\f.o")); // `..` does not climb above the share. - EXPECT_EQ(mcpplibs::os::fs::windows_extended("\\\\server\\share\\..\\..\\f"), - "\\\\?\\UNC\\server\\share\\f"); -} + expect(eq(mcpplibs::os::fs::windows_extended("\\\\server\\share\\..\\..\\f"), "\\\\?\\UNC\\server\\share\\f")); +}}; -TEST(PlatformFs, WindowsExtendedLengthLeavesPrefixedAndRelativePathsAlone) { +UTest windows_extended_length_leaves_prefixed_and_relative_paths_alone{"PlatformFs/WindowsExtendedLengthLeavesPrefixedAndRelativePathsAlone", [] { using mcpplibs::os::fs::windows_extended; // Idempotent: a path handed on to a child process is converted again there. - EXPECT_EQ(mcpplibs::os::fs::windows_extended("\\\\?\\C:\\a\\b"), "\\\\?\\C:\\a\\b"); - EXPECT_EQ(mcpplibs::os::fs::windows_extended("\\\\.\\pipe\\x"), "\\\\.\\pipe\\x"); - EXPECT_EQ(mcpplibs::os::fs::windows_extended("obj/a.ddi"), "obj/a.ddi"); - EXPECT_EQ(mcpplibs::os::fs::windows_extended(""), ""); -} + expect(eq(mcpplibs::os::fs::windows_extended("\\\\?\\C:\\a\\b"), "\\\\?\\C:\\a\\b")); + expect(eq(mcpplibs::os::fs::windows_extended("\\\\.\\pipe\\x"), "\\\\.\\pipe\\x")); + expect(eq(mcpplibs::os::fs::windows_extended("obj/a.ddi"), "obj/a.ddi")); + expect(eq(mcpplibs::os::fs::windows_extended(""), "")); +}}; // A NAME THE SHELL ANSWERS FOR ITSELF still resolves to the program on PATH. // `command -v true` prints `true`, because a shell runs its own builtin, and // the path is then found by walking PATH (mcpp#755: a bare-name payload // override of such a name was refused as "not found on PATH" on a machine // carrying /usr/bin/true). -TEST(PlatformFs, WhichResolvesANameThatIsAlsoAShellBuiltin) { +UTest which_resolves_a_name_that_is_also_a_shell_builtin{"PlatformFs/WhichResolvesANameThatIsAlsoAShellBuiltin", [] { #if defined(_WIN32) - GTEST_SKIP() << "`where` reports programs only; the shell answers nothing"; + skip("`where` reports programs only; the shell answers nothing"); #else std::error_code ec; if (!std::filesystem::is_regular_file("/usr/bin/true", ec) && !std::filesystem::is_regular_file("/bin/true", ec)) - GTEST_SKIP() << "this host has no `true` program to find"; + skip("this host has no `true` program to find"); const auto found = mcpp::invocation::which("true"); - ASSERT_TRUE(found.has_value()); - EXPECT_EQ(found->filename(), "true"); - EXPECT_TRUE(std::filesystem::is_regular_file(*found, ec)) << found->string(); + expect(fatal(found.has_value())); + expect(eq(found->filename(), "true")); + if (auto&& failed = expect(std::filesystem::is_regular_file(*found, ec)); !failed) failed << found->string(); #endif -} +}}; -TEST(PlatformFs, WhichStillAnswersNothingForANameNoHostHas) { - EXPECT_FALSE(mcpp::invocation::which("mcpp-no-such-program-7c25d9f6").has_value()); -} +UTest which_still_answers_nothing_for_a_name_no_host_has{"PlatformFs/WhichStillAnswersNothingForANameNoHostHas", [] { + expect(!(mcpp::invocation::which("mcpp-no-such-program-7c25d9f6").has_value())); +}}; diff --git a/tests/unit/test_platform_terminal.cpp b/tests/unit/test_platform_terminal.cpp index 9dc788b01..d4fe131ed 100644 --- a/tests/unit/test_platform_terminal.cpp +++ b/tests/unit/test_platform_terminal.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.ui.terminal; +using namespace mcpplibs::mtest; + // The keys a game reads from a POSIX terminal // (.agents/docs/2026-09-30-build-output-refinement-design.md, §5.14): the // decoder turns the bytes the terminal sent into keys, skips a sequence for @@ -11,35 +13,35 @@ import mcpp.ui.terminal; using mcpp::ui::terminal::Key; using mcpp::ui::terminal::decode_keys; -TEST(TerminalKeys, ArrowsInBothModesAndTheSecondSet) { +UTest arrows_in_both_modes_and_the_second_set{"TerminalKeys/ArrowsInBothModesAndTheSecondSet", [] { std::string in = "\x1b[A\x1b[B\x1bOC\x1bOD wasd"; const auto keys = decode_keys(in); const std::vector want = {Key::Up, Key::Down, Key::Right, Key::Left, Key::Space, Key::Up, Key::Left, Key::Down, Key::Right}; - EXPECT_EQ(keys, want); - EXPECT_TRUE(in.empty()); -} + expect(eq(keys, want)); + expect(in.empty()); +}}; -TEST(TerminalKeys, AModifiedArrowIsOneKeyAndOtherSequencesAreSkippedWhole) { +UTest a_modified_arrow_is_one_key_and_other_sequences_are_skipped_whole{"TerminalKeys/AModifiedArrowIsOneKeyAndOtherSequencesAreSkippedWhole", [] { // Ctrl-Up, then F5 (`ESC [ 1 5 ~`), then Home in application mode. std::string in = "\x1b[1;5A\x1b[15~\x1bOH"; const auto keys = decode_keys(in); const std::vector want = {Key::Up}; - EXPECT_EQ(keys, want) << "a parameter byte or a final byte was read as a key"; - EXPECT_TRUE(in.empty()); -} + expect(eq(keys, want)) << "a parameter byte or a final byte was read as a key"; + expect(in.empty()); +}}; -TEST(TerminalKeys, AnIncompleteSequenceWaitsForTheNextRead) { +UTest an_incomplete_sequence_waits_for_the_next_read{"TerminalKeys/AnIncompleteSequenceWaitsForTheNextRead", [] { std::string in = "d\x1b[1;"; - EXPECT_EQ(decode_keys(in), std::vector{Key::Right}); - EXPECT_EQ(in, "\x1b[1;"); + expect(eq(decode_keys(in), std::vector{Key::Right})); + expect(eq(in, "\x1b[1;")); in += "2B"; - EXPECT_EQ(decode_keys(in), std::vector{Key::Down}); - EXPECT_TRUE(in.empty()); + expect(eq(decode_keys(in), std::vector{Key::Down})); + expect(in.empty()); std::string esc = "\x1b"; - EXPECT_TRUE(decode_keys(esc).empty()); - EXPECT_EQ(esc, "\x1b"); + expect(decode_keys(esc).empty()); + expect(eq(esc, "\x1b")); esc += "w"; // escape on its own, then a key - EXPECT_EQ(decode_keys(esc), std::vector{Key::Up}); -} + expect(eq(decode_keys(esc), std::vector{Key::Up})); +}}; diff --git a/tests/unit/test_pm_bare_name_window.cpp b/tests/unit/test_pm_bare_name_window.cpp index b9b48841a..a640f4b26 100644 --- a/tests/unit/test_pm_bare_name_window.cpp +++ b/tests/unit/test_pm_bare_name_window.cpp @@ -1,37 +1,39 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pm.dep_spec; import mcpp.pm.dependency_selector; +using namespace mcpplibs::mtest; + // The one-release exit ramp for namespace-omitted selectors. Kept in its // own file so removing the window in 2026.9 is one deletion, not an // archaeology exercise across the resolver tests. -TEST(PmBareNameWindow, LegacyRungsExistOnlyForAnOmittedNamespace) { +UTest legacy_rungs_exist_only_for_an_omitted_namespace{"PmBareNameWindow/LegacyRungsExistOnlyForAnOmittedNamespace", [] { // Eligible: the author omitted the namespace, so `mcpplibs` was supplied. auto omitted = mcpp::pm::legacy_bare_candidates( mcpp::pm::DependencyCoordinate{ .namespace_ = std::string(mcpp::pm::kDefaultNamespace), .shortName = "gtest", }); - ASSERT_EQ(omitted.size(), 2u); - EXPECT_EQ(omitted[0].namespace_, mcpp::pm::kCompatNamespace); - EXPECT_EQ(omitted[0].shortName, "gtest"); + expect(fatal(eq(omitted.size(), 2u))); + expect(eq(omitted[0].namespace_, mcpp::pm::kCompatNamespace)); + expect(eq(omitted[0].shortName, "gtest")); // The namespace-less discovery rung, for upstream descriptors that declare // no namespace at all. Callers must still reject a descriptor that DOES // declare one, or this is the cross-namespace wildcard #278 removed. - EXPECT_TRUE(omitted[1].namespace_.empty()); - EXPECT_EQ(omitted[1].shortName, "gtest"); + expect(omitted[1].namespace_.empty()); + expect(eq(omitted[1].shortName, "gtest")); // Not eligible: every one of these STATES an identity. A stated identity // that misses is a miss — the window exists for spellings that predate the // rule, not for ones that break it. for (std::string_view ns : { "compat", "acme", "mcpplibs.capi" }) { - EXPECT_TRUE(mcpp::pm::legacy_bare_candidates( + expect(mcpp::pm::legacy_bare_candidates( mcpp::pm::DependencyCoordinate{ .namespace_ = std::string(ns), .shortName = "gtest", }).empty()) << ns; } -} +}}; diff --git a/tests/unit/test_pm_compat.cpp b/tests/unit/test_pm_compat.cpp index 7e6b172b2..ee1db9923 100644 --- a/tests/unit/test_pm_compat.cpp +++ b/tests/unit/test_pm_compat.cpp @@ -1,117 +1,115 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pm.compat; import mcpp.pm.dependency_selector; -TEST(PmCompat, InstallDirCandidatesIncludeNestedNamespaceFallback) { +using namespace mcpplibs::mtest; + +UTest install_dir_candidates_include_nested_namespace_fallback{"PmCompat/InstallDirCandidatesIncludeNestedNamespaceFallback", [] { auto candidates = mcpp::pm::compat::install_dir_candidates( "mcpplibs", "capi.lua", "mcpplibs"); - EXPECT_NE( - std::find(candidates.begin(), candidates.end(), - "mcpplibs.capi-x-mcpplibs.capi.lua"), - candidates.end()); -} + expect(ne(std::find(candidates.begin(), candidates.end(), + "mcpplibs.capi-x-mcpplibs.capi.lua"), candidates.end())); +}}; -TEST(PmCompat, NormalizeNestedNamespacePreservesQualifiedName) { +UTest normalize_nested_namespace_preserves_qualified_name{"PmCompat/NormalizeNestedNamespacePreservesQualifiedName", [] { std::string ns = "mcpplibs"; std::string shortName = "capi.lua"; mcpp::pm::compat::normalize_nested_namespace(ns, shortName, /*legacyDottedKey=*/true); - EXPECT_EQ(ns, "mcpplibs.capi"); - EXPECT_EQ(shortName, "lua"); - EXPECT_EQ(mcpp::pm::compat::qualified_name(ns, shortName), - "mcpplibs.capi.lua"); -} + expect(eq(ns, "mcpplibs.capi")); + expect(eq(shortName, "lua")); + expect(eq(mcpp::pm::compat::qualified_name(ns, shortName), "mcpplibs.capi.lua")); +}}; -TEST(PmCompat, SplitLegacyDependencyKeyMarksDottedKeyAsCompat) { +UTest split_legacy_dependency_key_marks_dotted_key_as_compat{"PmCompat/SplitLegacyDependencyKeyMarksDottedKeyAsCompat", [] { auto key = mcpp::pm::compat::split_legacy_dependency_key( "mcpplibs.capi.lua"); - EXPECT_EQ(key.namespace_, "mcpplibs"); - EXPECT_EQ(key.shortName, "capi.lua"); - EXPECT_TRUE(key.legacyDottedKey); -} + expect(eq(key.namespace_, "mcpplibs")); + expect(eq(key.shortName, "capi.lua")); + expect(bool(key.legacyDottedKey)); +}}; -TEST(PmCompat, NormalizeNestedNamespaceSkipsCanonicalNamespacedDeps) { +UTest normalize_nested_namespace_skips_canonical_namespaced_deps{"PmCompat/NormalizeNestedNamespaceSkipsCanonicalNamespacedDeps", [] { std::string ns = "mcpplibs.capi"; std::string shortName = "lua.extra"; mcpp::pm::compat::normalize_nested_namespace(ns, shortName, /*legacyDottedKey=*/false); - EXPECT_EQ(ns, "mcpplibs.capi"); - EXPECT_EQ(shortName, "lua.extra"); -} + expect(eq(ns, "mcpplibs.capi")); + expect(eq(shortName, "lua.extra")); +}}; -TEST(DependencySelector, DottedSelectorIsOneExactNamespace) { +UTest dotted_selector_is_one_exact_namespace{"DependencySelector/DottedSelectorIsOneExactNamespace", [] { auto selector = mcpp::pm::resolve_dependency_selector( "imgui.backend.glfw_opengl3"); - EXPECT_EQ(selector.stableMapKey, "imgui.backend.glfw_opengl3"); - ASSERT_EQ(selector.candidates.size(), 1u); - EXPECT_EQ(selector.candidates[0].namespace_, "imgui.backend"); - EXPECT_EQ(selector.candidates[0].shortName, "glfw_opengl3"); -} + expect(eq(selector.stableMapKey, "imgui.backend.glfw_opengl3")); + expect(fatal(eq(selector.candidates.size(), 1u))); + expect(eq(selector.candidates[0].namespace_, "imgui.backend")); + expect(eq(selector.candidates[0].shortName, "glfw_opengl3")); +}}; -TEST(DependencySelector, BareSelectorUsesOnlyDefaultNamespace) { +UTest bare_selector_uses_only_default_namespace{"DependencySelector/BareSelectorUsesOnlyDefaultNamespace", [] { auto selector = mcpp::pm::resolve_dependency_selector("imgui"); - EXPECT_EQ(selector.stableMapKey, "imgui"); - ASSERT_EQ(selector.candidates.size(), 1u); - EXPECT_EQ(selector.candidates[0].namespace_, "mcpplibs"); - EXPECT_EQ(selector.candidates[0].shortName, "imgui"); -} + expect(eq(selector.stableMapKey, "imgui")); + expect(fatal(eq(selector.candidates.size(), 1u))); + expect(eq(selector.candidates[0].namespace_, "mcpplibs")); + expect(eq(selector.candidates[0].shortName, "imgui")); +}}; -TEST(DependencySelector, ExplicitMcpplibsPrefixDoesNotAddPeerFallback) { +UTest explicit_mcpplibs_prefix_does_not_add_peer_fallback{"DependencySelector/ExplicitMcpplibsPrefixDoesNotAddPeerFallback", [] { auto selector = mcpp::pm::resolve_dependency_selector( "mcpplibs.capi.lua"); - EXPECT_EQ(selector.stableMapKey, "mcpplibs.capi.lua"); - ASSERT_EQ(selector.candidates.size(), 1u); - EXPECT_EQ(selector.candidates[0].namespace_, "mcpplibs.capi"); - EXPECT_EQ(selector.candidates[0].shortName, "lua"); -} + expect(eq(selector.stableMapKey, "mcpplibs.capi.lua")); + expect(fatal(eq(selector.candidates.size(), 1u))); + expect(eq(selector.candidates[0].namespace_, "mcpplibs.capi")); + expect(eq(selector.candidates[0].shortName, "lua")); +}}; -TEST(DependencySelector, ExplicitRootSelectorHasOnlyThatRoot) { +UTest explicit_root_selector_has_only_that_root{"DependencySelector/ExplicitRootSelectorHasOnlyThatRoot", [] { auto selector = mcpp::pm::make_direct_dependency_selector( "compat", "gtest", "compat.gtest"); - EXPECT_EQ(selector.stableMapKey, "compat.gtest"); - ASSERT_EQ(selector.candidates.size(), 1u); - EXPECT_EQ(selector.candidates[0].namespace_, "compat"); - EXPECT_EQ(selector.candidates[0].shortName, "gtest"); -} + expect(eq(selector.stableMapKey, "compat.gtest")); + expect(fatal(eq(selector.candidates.size(), 1u))); + expect(eq(selector.candidates[0].namespace_, "compat")); + expect(eq(selector.candidates[0].shortName, "gtest")); +}}; -TEST(DependencySelector, SharedParserNormalizesBareAndDottedSelectors) { +UTest shared_parser_normalizes_bare_and_dotted_selectors{"DependencySelector/SharedParserNormalizesBareAndDottedSelectors", [] { auto bare = mcpp::pm::parse_package_selector("lua"); - ASSERT_TRUE(bare.has_value()); - EXPECT_FALSE(bare->namespace_.has_value()); - EXPECT_EQ(bare->name, "lua"); - EXPECT_EQ(bare->spelling, "lua"); + expect(fatal(bare.has_value())); + expect(!(bare->namespace_.has_value())); + expect(eq(bare->name, "lua")); + expect(eq(bare->spelling, "lua")); auto bareCoord = mcpp::pm::normalize_package_selector(*bare); - EXPECT_EQ(bareCoord.namespace_, "mcpplibs"); - EXPECT_EQ(bareCoord.shortName, "lua"); - EXPECT_EQ(mcpp::pm::format_package_selector(bareCoord), "lua"); + expect(eq(bareCoord.namespace_, "mcpplibs")); + expect(eq(bareCoord.shortName, "lua")); + expect(eq(mcpp::pm::format_package_selector(bareCoord), "lua")); auto nested = mcpp::pm::parse_package_selector("mcpplibs.capi.lua"); - ASSERT_TRUE(nested.has_value()); - ASSERT_TRUE(nested->namespace_.has_value()); - EXPECT_EQ(*nested->namespace_, "mcpplibs.capi"); - EXPECT_EQ(nested->name, "lua"); + expect(fatal(nested.has_value())); + expect(fatal(nested->namespace_.has_value())); + expect(eq(*nested->namespace_, "mcpplibs.capi")); + expect(eq(nested->name, "lua")); auto nestedCoord = mcpp::pm::normalize_package_selector(*nested); - EXPECT_EQ(nestedCoord.namespace_, "mcpplibs.capi"); - EXPECT_EQ(nestedCoord.shortName, "lua"); - EXPECT_EQ(mcpp::pm::format_package_selector(nestedCoord), - "mcpplibs.capi.lua"); -} + expect(eq(nestedCoord.namespace_, "mcpplibs.capi")); + expect(eq(nestedCoord.shortName, "lua")); + expect(eq(mcpp::pm::format_package_selector(nestedCoord), "mcpplibs.capi.lua")); +}}; -TEST(DependencySelector, SharedParserRejectsAmbiguousOrUnsafeSegments) { +UTest shared_parser_rejects_ambiguous_or_unsafe_segments{"DependencySelector/SharedParserRejectsAmbiguousOrUnsafeSegments", [] { const std::vector invalid{ "", ".imgui", @@ -130,84 +128,84 @@ TEST(DependencySelector, SharedParserRejectsAmbiguousOrUnsafeSegments) { }; for (auto const& spelling : invalid) { auto parsed = mcpp::pm::parse_package_selector(spelling); - EXPECT_FALSE(parsed.has_value()) << spelling; + expect(!parsed.has_value()) << spelling; if (!parsed) - EXPECT_FALSE(parsed.error().message.empty()) << spelling; + expect(!(parsed.error().message.empty())) << spelling; } -} +}}; -TEST(DependencySelector, LegacyDottedPrimaryIsExplicitAndNeverImplicitlyUsed) { +UTest legacy_dotted_primary_is_explicit_and_never_implicitly_used{"DependencySelector/LegacyDottedPrimaryIsExplicitAndNeverImplicitlyUsed", [] { auto capi = mcpp::pm::parse_package_selector("capi.lua"); - ASSERT_TRUE(capi.has_value()); + expect(fatal(capi.has_value())); auto legacy = mcpp::pm::legacy_prefixed_coordinate( mcpp::pm::normalize_package_selector(*capi)); - ASSERT_TRUE(legacy.has_value()); - EXPECT_EQ(legacy->namespace_, "mcpplibs.capi"); - EXPECT_EQ(legacy->shortName, "lua"); + expect(fatal(legacy.has_value())); + expect(eq(legacy->namespace_, "mcpplibs.capi")); + expect(eq(legacy->shortName, "lua")); auto explicitDefault = mcpp::pm::parse_package_selector( "mcpplibs.capi.lua"); - ASSERT_TRUE(explicitDefault.has_value()); - EXPECT_FALSE(mcpp::pm::legacy_prefixed_coordinate( - mcpp::pm::normalize_package_selector(*explicitDefault)).has_value()); + expect(fatal(explicitDefault.has_value())); + expect(!(mcpp::pm::legacy_prefixed_coordinate( + mcpp::pm::normalize_package_selector(*explicitDefault)).has_value())); auto bare = mcpp::pm::parse_package_selector("lua"); - ASSERT_TRUE(bare.has_value()); - EXPECT_FALSE(mcpp::pm::legacy_prefixed_coordinate( - mcpp::pm::normalize_package_selector(*bare)).has_value()); -} + expect(fatal(bare.has_value())); + expect(!(mcpp::pm::legacy_prefixed_coordinate( + mcpp::pm::normalize_package_selector(*bare)).has_value())); +}}; // ─── descriptor_coordinates (package-template fetch) ──────────────── -TEST(PmCompat, DescriptorCoordinatesLegacyEmbeddedNamespace) { +UTest descriptor_coordinates_legacy_embedded_namespace{"PmCompat/DescriptorCoordinatesLegacyEmbeddedNamespace", [] { // pkgs/l/llmapi.lua: namespace = "mcpplibs", name = "mcpplibs.llmapi" auto r = mcpp::pm::compat::descriptor_coordinates( "llmapi", "mcpplibs", "mcpplibs.llmapi"); - EXPECT_EQ(r.namespace_, "mcpplibs"); - EXPECT_EQ(r.shortName, "llmapi"); - EXPECT_TRUE(r.usedLegacySplit); -} + expect(eq(r.namespace_, "mcpplibs")); + expect(eq(r.shortName, "llmapi")); + expect(bool(r.usedLegacySplit)); +}}; -TEST(PmCompat, DescriptorCoordinatesCanonicalNamespaceField) { +UTest descriptor_coordinates_canonical_namespace_field{"PmCompat/DescriptorCoordinatesCanonicalNamespaceField", [] { auto r = mcpp::pm::compat::descriptor_coordinates( "llmapi", "mcpplibs", "llmapi"); - EXPECT_EQ(r.namespace_, "mcpplibs"); - EXPECT_EQ(r.shortName, "llmapi"); - EXPECT_FALSE(r.usedLegacySplit); -} + expect(eq(r.namespace_, "mcpplibs")); + expect(eq(r.shortName, "llmapi")); + expect(!r.usedLegacySplit); +}}; -TEST(PmCompat, DescriptorCoordinatesRootPackageStaysInRoot) { +UTest descriptor_coordinates_root_package_stays_in_root{"PmCompat/DescriptorCoordinatesRootPackageStaysInRoot", [] { // pkgs/i/imgui.lua: namespace = "", name = "imgui" — must NOT be // promoted to the default namespace (it installs by its bare name). auto r = mcpp::pm::compat::descriptor_coordinates("imgui", "", "imgui"); - EXPECT_EQ(r.namespace_, ""); - EXPECT_EQ(r.shortName, "imgui"); - EXPECT_FALSE(r.usedLegacySplit); -} + expect(eq(r.namespace_, "")); + expect(eq(r.shortName, "imgui")); + expect(!r.usedLegacySplit); +}}; -TEST(PmCompat, DescriptorCoordinatesLegacyDottedNameWithoutNamespace) { +UTest descriptor_coordinates_legacy_dotted_name_without_namespace{"PmCompat/DescriptorCoordinatesLegacyDottedNameWithoutNamespace", [] { auto r = mcpp::pm::compat::descriptor_coordinates( "tinyhttps", "", "mcpplibs.tinyhttps"); - EXPECT_EQ(r.namespace_, "mcpplibs"); - EXPECT_EQ(r.shortName, "tinyhttps"); - EXPECT_TRUE(r.usedLegacySplit); -} + expect(eq(r.namespace_, "mcpplibs")); + expect(eq(r.shortName, "tinyhttps")); + expect(bool(r.usedLegacySplit)); +}}; -TEST(PmCompat, DescriptorCoordinatesFallsBackToSpecWhenNameMissing) { +UTest descriptor_coordinates_falls_back_to_spec_when_name_missing{"PmCompat/DescriptorCoordinatesFallsBackToSpecWhenNameMissing", [] { auto r = mcpp::pm::compat::descriptor_coordinates("imgui", "", ""); - EXPECT_EQ(r.namespace_, ""); - EXPECT_EQ(r.shortName, "imgui"); -} + expect(eq(r.namespace_, "")); + expect(eq(r.shortName, "imgui")); +}}; -TEST(PmCompat, DescriptorCoordinatesCompatNamespace) { +UTest descriptor_coordinates_compat_namespace{"PmCompat/DescriptorCoordinatesCompatNamespace", [] { auto r = mcpp::pm::compat::descriptor_coordinates( "mbedtls", "compat", "compat.mbedtls"); - EXPECT_EQ(r.namespace_, "compat"); - EXPECT_EQ(r.shortName, "mbedtls"); -} + expect(eq(r.namespace_, "compat")); + expect(eq(r.shortName, "mbedtls")); +}}; diff --git a/tests/unit/test_pm_index_refresh.cpp b/tests/unit/test_pm_index_refresh.cpp index 69668bec4..122976fec 100644 --- a/tests/unit/test_pm_index_refresh.cpp +++ b/tests/unit/test_pm_index_refresh.cpp @@ -12,9 +12,9 @@ // host publishes no 1.2.3" is not "1.2.3 does not exist". // ColdStartIgnoresDebounce — an absent index has nothing to debounce against. -#include import std; +import mcpplibs.mtest; import mcpp.config; import mcpp.pm.dep_spec; import mcpp.pm.index_refresh; @@ -23,6 +23,8 @@ import mcpp.pm.index_spec; import mcpp.toolchain.platform; import mcpp.provider.xlings; +using namespace mcpplibs::mtest; + namespace { using mcpp::pm::RefreshReason; @@ -139,7 +141,7 @@ constexpr std::string_view kOneVersion = R"(["1.2.0"] = { url = "u", sha256 = "s // ─── Rows that never reach the filesystem ──────────────────────────────── -TEST(PmIndexRefresh, PathAndGitDependenciesNeverRefresh) { +UTest path_and_git_dependencies_never_refresh{"PmIndexRefresh/PathAndGitDependenciesNeverRefresh", [] { FakeRegistry reg("srcdeps"); mcpp::pm::IndexMap indices; auto cfg = reg.config(); @@ -147,14 +149,14 @@ TEST(PmIndexRefresh, PathAndGitDependenciesNeverRefresh) { mcpp::pm::DependencySpec pathDep; pathDep.path = "../sibling"; - EXPECT_EQ(decide(route, pathDep, reg.env()), RefreshReason::None); + expect(eq(decide(route, pathDep, reg.env()), RefreshReason::None)); mcpp::pm::DependencySpec gitDep; gitDep.git = "https://example.invalid/x.git"; - EXPECT_EQ(decide(route, gitDep, reg.env()), RefreshReason::None); -} + expect(eq(decide(route, gitDep, reg.env()), RefreshReason::None)); +}}; -TEST(PmIndexRefresh, OfflineSuppressesEvenAGenuineMiss) { +UTest offline_suppresses_even_a_genuine_miss{"PmIndexRefresh/OfflineSuppressesEvenAGenuineMiss", [] { FakeRegistry reg("offline"); reg.mark_refreshed(std::chrono::hours{99}); mcpp::pm::IndexMap indices; @@ -164,16 +166,16 @@ TEST(PmIndexRefresh, OfflineSuppressesEvenAGenuineMiss) { auto spec = version_dep("mcpplibs", "absent", "1.0.0"); mcpp::pm::RefreshPolicy offline; offline.offline = true; - EXPECT_EQ(decide(route, spec, reg.env(), offline), RefreshReason::SuppressedOffline); - EXPECT_FALSE(refreshes(route, spec, reg.env(), offline)); -} + expect(eq(decide(route, spec, reg.env(), offline), RefreshReason::SuppressedOffline)); + expect(!(refreshes(route, spec, reg.env(), offline))); +}}; // mcpp-community/mcpp#648. `ftxui = "6.1.9"` omits the namespace, so the // exact coordinate is (mcpplibs, ftxui), which the index does not carry; the // resolver then reaches compat.ftxui through the deprecated bare-name rung and // the build succeeds from disk. The decision must walk the same rung, or every // build of such a manifest refreshes the index once the debounce has passed. -TEST(PmIndexRefresh, BareNameResolvedThroughTheLegacyRungIsNotAMiss) { +UTest bare_name_resolved_through_the_legacy_rung_is_not_a_miss{"PmIndexRefresh/BareNameResolvedThroughTheLegacyRungIsNotAMiss", [] { FakeRegistry reg("legacyrung"); reg.publish_namespaced("compat", "ftxui", R"(["6.1.9"] = { url = "u", sha256 = "s" },)"); reg.mark_refreshed(std::chrono::hours{99}); @@ -183,21 +185,21 @@ TEST(PmIndexRefresh, BareNameResolvedThroughTheLegacyRungIsNotAMiss) { auto bare = version_dep("mcpplibs", "ftxui", "6.1.9"); bare.namespaceOmitted = true; - EXPECT_EQ(decide(route, bare, reg.env()), RefreshReason::None); - EXPECT_FALSE(refreshes(route, bare, reg.env())); + expect(eq(decide(route, bare, reg.env()), RefreshReason::None)); + expect(!(refreshes(route, bare, reg.env()))); // A constraint is checked against the descriptor the rung found. auto bareRange = version_dep("mcpplibs", "ftxui", "^7"); bareRange.namespaceOmitted = true; - EXPECT_EQ(decide(route, bareRange, reg.env()), RefreshReason::VersionMiss); + expect(eq(decide(route, bareRange, reg.env()), RefreshReason::VersionMiss)); // The namespace written out states an identity: the rung does not apply, // exactly as in the resolver, and the miss stands. auto stated = version_dep("mcpplibs", "ftxui", "6.1.9"); - EXPECT_EQ(decide(route, stated, reg.env()), RefreshReason::DescriptorMiss); -} + expect(eq(decide(route, stated, reg.env()), RefreshReason::DescriptorMiss)); +}}; -TEST(PmIndexRefresh, OfflineDoesNotMisreportAResolvableDependency) { +UTest offline_does_not_misreport_a_resolvable_dependency{"PmIndexRefresh/OfflineDoesNotMisreportAResolvableDependency", [] { // The opt-outs are applied AFTER the local analysis, so a dependency that // resolved fine is reported as such even offline. Short-circuiting on the // flag first would answer "offline mode" for every dependency — hiding @@ -210,11 +212,10 @@ TEST(PmIndexRefresh, OfflineDoesNotMisreportAResolvableDependency) { mcpp::pm::IndexRoute route{ &indices, "/nowhere", &cfg }; mcpp::pm::RefreshPolicy offline; offline.offline = true; - EXPECT_EQ(decide(route, version_dep("mcpplibs", "widget", "1.2.0"), reg.env(), offline), - RefreshReason::None); -} + expect(eq(decide(route, version_dep("mcpplibs", "widget", "1.2.0"), reg.env(), offline), RefreshReason::None)); +}}; -TEST(PmIndexRefresh, AutoRefreshOptOutSuppressesAGenuineMiss) { +UTest auto_refresh_opt_out_suppresses_a_genuine_miss{"PmIndexRefresh/AutoRefreshOptOutSuppressesAGenuineMiss", [] { FakeRegistry reg("optout"); reg.mark_refreshed(std::chrono::hours{99}); mcpp::pm::IndexMap indices; @@ -224,12 +225,12 @@ TEST(PmIndexRefresh, AutoRefreshOptOutSuppressesAGenuineMiss) { auto spec = version_dep("mcpplibs", "absent", "1.0.0"); mcpp::pm::RefreshPolicy off; off.autoRefresh = false; - EXPECT_EQ(decide(route, spec, reg.env(), off), RefreshReason::SuppressedDisabled); -} + expect(eq(decide(route, spec, reg.env(), off), RefreshReason::SuppressedDisabled)); +}}; // ─── INV-3: a miss must be refutable to mean anything ──────────────────── -TEST(PmIndexRefresh, InconclusiveNamespaceNeverRefreshes) { +UTest inconclusive_namespace_never_refreshes{"PmIndexRefresh/InconclusiveNamespaceNeverRefreshes", [] { FakeRegistry reg("inconclusive"); reg.mark_refreshed(std::chrono::hours{99}); mcpp::pm::IndexMap indices; @@ -241,13 +242,13 @@ TEST(PmIndexRefresh, InconclusiveNamespaceNeverRefreshes) { // so "absent" here is not evidence of anything. for (auto ns : { "xim", "somevendor" }) { auto spec = version_dep(ns, "thing", "1.0.0"); - EXPECT_EQ(decide(route, spec, reg.env()), RefreshReason::SuppressedInconclusive) + expect(eq(decide(route, spec, reg.env()), RefreshReason::SuppressedInconclusive)) << "namespace: " << ns; - EXPECT_FALSE(refreshes(route, spec, reg.env())) << "namespace: " << ns; + expect(!(refreshes(route, spec, reg.env()))) << "namespace: " << ns; } -} +}}; -TEST(PmIndexRefresh, LazyGitIndexNeverRefreshesTheGlobalOne) { +UTest lazy_git_index_never_refreshes_the_global_one{"PmIndexRefresh/LazyGitIndexNeverRefreshesTheGlobalOne", [] { FakeRegistry reg("lazygit"); reg.mark_refreshed(std::chrono::hours{99}); mcpp::pm::IndexMap indices; @@ -257,12 +258,12 @@ TEST(PmIndexRefresh, LazyGitIndexNeverRefreshesTheGlobalOne) { // Served by a custom index; syncing the shared registry would not help. auto spec = version_dep("acme", "widget", "1.0.0"); - EXPECT_EQ(decide(route, spec, reg.env()), RefreshReason::None); -} + expect(eq(decide(route, spec, reg.env()), RefreshReason::None)); +}}; // ─── The three ways local state fails to answer ────────────────────────── -TEST(PmIndexRefresh, PresentDescriptorIsTheSteadyStateAndCostsNothing) { +UTest present_descriptor_is_the_steady_state_and_costs_nothing{"PmIndexRefresh/PresentDescriptorIsTheSteadyStateAndCostsNothing", [] { FakeRegistry reg("steady"); reg.publish("widget", kOneVersion); reg.mark_refreshed(std::chrono::hours{99}); // old, and irrelevant @@ -270,13 +271,11 @@ TEST(PmIndexRefresh, PresentDescriptorIsTheSteadyStateAndCostsNothing) { auto cfg = reg.config(); mcpp::pm::IndexRoute route{ &indices, "/nowhere", &cfg }; - EXPECT_EQ(decide(route, version_dep("mcpplibs", "widget", "1.2.0"), reg.env()), - RefreshReason::None); - EXPECT_EQ(decide(route, version_dep("mcpplibs", "widget", "^1.2"), reg.env()), - RefreshReason::None); -} + expect(eq(decide(route, version_dep("mcpplibs", "widget", "1.2.0"), reg.env()), RefreshReason::None)); + expect(eq(decide(route, version_dep("mcpplibs", "widget", "^1.2"), reg.env()), RefreshReason::None)); +}}; -TEST(PmIndexRefresh, MissingDescriptorTriggersARefresh) { +UTest missing_descriptor_triggers_a_refresh{"PmIndexRefresh/MissingDescriptorTriggersARefresh", [] { FakeRegistry reg("descmiss"); reg.publish("widget", kOneVersion); reg.mark_refreshed(std::chrono::hours{99}); @@ -285,11 +284,11 @@ TEST(PmIndexRefresh, MissingDescriptorTriggersARefresh) { mcpp::pm::IndexRoute route{ &indices, "/nowhere", &cfg }; auto spec = version_dep("mcpplibs", "absent", "1.0.0"); - EXPECT_EQ(decide(route, spec, reg.env()), RefreshReason::DescriptorMiss); - EXPECT_TRUE(refreshes(route, spec, reg.env())); -} + expect(eq(decide(route, spec, reg.env()), RefreshReason::DescriptorMiss)); + expect(bool(refreshes(route, spec, reg.env()))); +}}; -TEST(PmIndexRefresh, UnsatisfiableConstraintTriggersARefresh) { +UTest unsatisfiable_constraint_triggers_a_refresh{"PmIndexRefresh/UnsatisfiableConstraintTriggersARefresh", [] { FakeRegistry reg("vermiss"); reg.publish("widget", kOneVersion); // only 1.2.0 exists reg.mark_refreshed(std::chrono::hours{99}); @@ -298,10 +297,10 @@ TEST(PmIndexRefresh, UnsatisfiableConstraintTriggersARefresh) { mcpp::pm::IndexRoute route{ &indices, "/nowhere", &cfg }; auto spec = version_dep("mcpplibs", "widget", "^9.9"); - EXPECT_EQ(decide(route, spec, reg.env()), RefreshReason::VersionMiss); -} + expect(eq(decide(route, spec, reg.env()), RefreshReason::VersionMiss)); +}}; -TEST(PmIndexRefresh, ExactVersionAbsentIsNotAVersionMiss) { +UTest exact_version_absent_is_not_a_version_miss{"PmIndexRefresh/ExactVersionAbsentIsNotAVersionMiss", [] { FakeRegistry reg("exactver"); reg.publish("widget", kOneVersion); // only 1.2.0 exists reg.mark_refreshed(std::chrono::hours{99}); @@ -313,12 +312,12 @@ TEST(PmIndexRefresh, ExactVersionAbsentIsNotAVersionMiss) { // simply not be built for this host. Refreshing on it would fire on EVERY // build for such a dependency. The install layer covers the real case. auto spec = version_dep("mcpplibs", "widget", "9.9.9"); - EXPECT_EQ(decide(route, spec, reg.env()), RefreshReason::None); -} + expect(eq(decide(route, spec, reg.env()), RefreshReason::None)); +}}; // ─── Debounce ──────────────────────────────────────────────────────────── -TEST(PmIndexRefresh, DebounceSuppressesTheSecondMissInAWindow) { +UTest debounce_suppresses_the_second_miss_in_a_window{"PmIndexRefresh/DebounceSuppressesTheSecondMissInAWindow", [] { FakeRegistry reg("debounce"); reg.publish("widget", kOneVersion); reg.mark_refreshed(std::chrono::seconds{5}); // just synced @@ -327,11 +326,11 @@ TEST(PmIndexRefresh, DebounceSuppressesTheSecondMissInAWindow) { mcpp::pm::IndexRoute route{ &indices, "/nowhere", &cfg }; auto spec = version_dep("mcpplibs", "absent", "1.0.0"); - EXPECT_EQ(decide(route, spec, reg.env()), RefreshReason::SuppressedDebounce); - EXPECT_FALSE(refreshes(route, spec, reg.env())); -} + expect(eq(decide(route, spec, reg.env()), RefreshReason::SuppressedDebounce)); + expect(!(refreshes(route, spec, reg.env()))); +}}; -TEST(PmIndexRefresh, ColdStartIgnoresDebounce) { +UTest cold_start_ignores_debounce{"PmIndexRefresh/ColdStartIgnoresDebounce", [] { FakeRegistry reg("coldstart"); reg.mark_refreshed(std::chrono::seconds{5}); reg.remove_index(); // marker gone with it; no local index at all @@ -340,35 +339,32 @@ TEST(PmIndexRefresh, ColdStartIgnoresDebounce) { mcpp::pm::IndexRoute route{ &indices, "/nowhere", &cfg }; auto spec = version_dep("mcpplibs", "widget", "1.0.0"); - EXPECT_EQ(decide(route, spec, reg.env()), RefreshReason::IndexAbsent); - EXPECT_TRUE(refreshes(route, spec, reg.env())); -} + expect(eq(decide(route, spec, reg.env()), RefreshReason::IndexAbsent)); + expect(bool(refreshes(route, spec, reg.env()))); +}}; // ─── decide_for_miss (the `mcpp add` half) ─────────────────────────────── -TEST(PmIndexRefresh, DecideForMissHonoursTheSameOptOutsAndDebounce) { +UTest decide_for_miss_honours_the_same_opt_outs_and_debounce{"PmIndexRefresh/DecideForMissHonoursTheSameOptOutsAndDebounce", [] { FakeRegistry reg("formiss"); reg.mark_refreshed(std::chrono::hours{99}); auto env = reg.env(); mcpp::pm::RefreshPolicy allowed; - EXPECT_TRUE(mcpp::pm::decide_for_miss(allowed, env, "pkg").shouldRefresh); + expect(bool(mcpp::pm::decide_for_miss(allowed, env, "pkg").shouldRefresh)); mcpp::pm::RefreshPolicy offline; offline.offline = true; - EXPECT_EQ(mcpp::pm::decide_for_miss(offline, env, "pkg").reason, - RefreshReason::SuppressedOffline); + expect(eq(mcpp::pm::decide_for_miss(offline, env, "pkg").reason, RefreshReason::SuppressedOffline)); mcpp::pm::RefreshPolicy disabled; disabled.autoRefresh = false; - EXPECT_EQ(mcpp::pm::decide_for_miss(disabled, env, "pkg").reason, - RefreshReason::SuppressedDisabled); + expect(eq(mcpp::pm::decide_for_miss(disabled, env, "pkg").reason, RefreshReason::SuppressedDisabled)); // Two `mcpp add` typos in a row must not buy two multi-repo syncs. reg.mark_refreshed(std::chrono::seconds{5}); - EXPECT_EQ(mcpp::pm::decide_for_miss(allowed, env, "pkg").reason, - RefreshReason::SuppressedDebounce); -} + expect(eq(mcpp::pm::decide_for_miss(allowed, env, "pkg").reason, RefreshReason::SuppressedDebounce)); +}}; -TEST(PmIndexRefresh, SubjectEchoesWhatTheUserWrote) { +UTest subject_echoes_what_the_user_wrote{"PmIndexRefresh/SubjectEchoesWhatTheUserWrote", [] { FakeRegistry reg("subject"); reg.mark_refreshed(std::chrono::hours{99}); mcpp::pm::IndexMap indices; @@ -382,5 +378,5 @@ TEST(PmIndexRefresh, SubjectEchoesWhatTheUserWrote) { auto d = mcpp::pm::decide_for_dependency( route, "xim.nasm", spec, reg.env(), mcpp::toolchain::platform::TargetPlatform::for_os("linux"), {}); - EXPECT_EQ(d.subject, "xim.nasm@2.16.03"); -} + expect(eq(d.subject, "xim.nasm@2.16.03")); +}}; diff --git a/tests/unit/test_pm_index_route.cpp b/tests/unit/test_pm_index_route.cpp index a7cf67258..f3326b93c 100644 --- a/tests/unit/test_pm_index_route.cpp +++ b/tests/unit/test_pm_index_route.cpp @@ -1,11 +1,13 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pm.dep_spec; import mcpp.pm.dependency_selector; import mcpp.pm.index_route; import mcpp.pm.index_spec; +using namespace mcpplibs::mtest; + namespace { struct LocalIndex { @@ -36,73 +38,72 @@ mcpp::pm::IndexMap local_map(const LocalIndex& idx) { } // namespace -TEST(PmIndexRoute, FindForNsResolvesNestedNamespaceToItsRootIndex) { +UTest find_for_ns_resolves_nested_namespace_to_its_root_index{"PmIndexRoute/FindForNsResolvesNestedNamespaceToItsRootIndex", [] { mcpp::pm::IndexMap indices; indices["acme"] = mcpp::pm::IndexSpec{ .name = "acme", .url = "https://x/y" }; mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; - EXPECT_NE(route.find_for_ns("acme"), nullptr); - EXPECT_NE(route.find_for_ns("acme.nested"), nullptr); - EXPECT_EQ(route.find_for_ns("other"), nullptr); -} + expect(ne(route.find_for_ns("acme"), nullptr)); + expect(ne(route.find_for_ns("acme.nested"), nullptr)); + expect(eq(route.find_for_ns("other"), nullptr)); +}}; -TEST(PmIndexRoute, LazyGitIndexCannotRefuteAMiss) { +UTest lazy_git_index_cannot_refute_a_miss{"PmIndexRoute/LazyGitIndexCannotRefuteAMiss", [] { mcpp::pm::IndexMap indices; indices["acme"] = mcpp::pm::IndexSpec{ .name = "acme", .url = "https://x/y" }; mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; - EXPECT_TRUE(route.lazy_git("acme")); - EXPECT_FALSE(route.authoritative_for("acme")); -} + expect(bool(route.lazy_git("acme"))); + expect(!route.authoritative_for("acme")); +}}; -TEST(PmIndexRoute, LocalPathIndexIsAuthoritative) { +UTest local_path_index_is_authoritative{"PmIndexRoute/LocalPathIndexIsAuthoritative", [] { LocalIndex idx("authoritative"); auto indices = local_map(idx); mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; - EXPECT_FALSE(route.lazy_git("acme")); - EXPECT_TRUE(route.authoritative_for("acme")); -} + expect(!route.lazy_git("acme")); + expect(bool(route.authoritative_for("acme"))); +}}; -TEST(PmIndexRoute, RouteDescriptionDoesNotExposeItsLocalPath) { +UTest route_description_does_not_expose_its_local_path{"PmIndexRoute/RouteDescriptionDoesNotExposeItsLocalPath", [] { LocalIndex idx("diagnostic-privacy"); auto indices = local_map(idx); mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; const auto description = route.describe("acme"); - EXPECT_EQ(description, "local index 'acme': root present, pkgs present"); - EXPECT_EQ(description.find(idx.root.string()), std::string::npos); -} + expect(eq(description, "local index 'acme': root present, pkgs present")); + expect(eq(description.find(idx.root.string()), std::string::npos)); +}}; -TEST(PmIndexRoute, RouteDescriptionDistinguishesAMissingLocalRoot) { +UTest route_description_distinguishes_a_missing_local_root{"PmIndexRoute/RouteDescriptionDistinguishesAMissingLocalRoot", [] { mcpp::pm::IndexMap indices; indices["acme"] = mcpp::pm::IndexSpec{ .name = "acme", .path = "missing-local-index" }; mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; - EXPECT_EQ(route.describe("acme"), - "local index 'acme': root absent, pkgs absent"); -} + expect(eq(route.describe("acme"), "local index 'acme': root absent, pkgs absent")); +}}; // The regression behind #305/#307: a dotted selector is one exact NAMESPACE // PATH, so it resolves through the coordinate the manifest parser derives. Probing the // literal short name can never match — `package.name` is a single atomic // segment, so nothing in any index is named "acme.util". -TEST(PmIndexRoute, DottedSelectorResolvesThroughItsExactCoordinate) { +UTest dotted_selector_resolves_through_its_exact_coordinate{"PmIndexRoute/DottedSelectorResolvesThroughItsExactCoordinate", [] { LocalIndex idx("dotted"); auto indices = local_map(idx); mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; auto selector = mcpp::pm::resolve_dependency_selector("acme.util"); - ASSERT_EQ(selector.candidates.size(), 1u); + expect(fatal(eq(selector.candidates.size(), 1u))); auto found = mcpp::pm::lookup_descriptor(route, selector.candidates); - ASSERT_TRUE(found.hit.has_value()); - EXPECT_EQ(found.hit->coord.namespace_, "acme"); - EXPECT_EQ(found.hit->coord.shortName, "util"); -} + expect(fatal(found.hit.has_value())); + expect(eq(found.hit->coord.namespace_, "acme")); + expect(eq(found.hit->coord.shortName, "util")); +}}; -TEST(PmIndexRoute, NameOnlyDescriptorInheritsOwningLocalIndexNamespace) { +UTest name_only_descriptor_inherits_owning_local_index_namespace{"PmIndexRoute/NameOnlyDescriptorInheritsOwningLocalIndexNamespace", [] { LocalIndex idx("name-only"); std::ofstream(idx.root / "pkgs" / "a" / "acme.util.lua") << R"( package = { @@ -117,12 +118,12 @@ package = { auto selector = mcpp::pm::resolve_dependency_selector("acme.util"); auto found = mcpp::pm::lookup_descriptor(route, selector.candidates); - ASSERT_TRUE(found.hit.has_value()); - EXPECT_EQ(found.hit->coord.namespace_, "acme"); - EXPECT_EQ(found.hit->coord.shortName, "util"); -} + expect(fatal(found.hit.has_value())); + expect(eq(found.hit->coord.namespace_, "acme")); + expect(eq(found.hit->coord.shortName, "util")); +}}; -TEST(PmIndexRoute, MalformedCandidateIsAnErrorNotANotFoundMiss) { +UTest malformed_candidate_is_an_error_not_a_not_found_miss{"PmIndexRoute/MalformedCandidateIsAnErrorNotANotFoundMiss", [] { LocalIndex idx("malformed"); std::ofstream(idx.root / "pkgs" / "a" / "acme.util.lua") << R"( package = { @@ -140,11 +141,11 @@ package = { auto selector = mcpp::pm::resolve_dependency_selector("acme.nested.util"); auto found = mcpp::pm::lookup_descriptor(route, selector.candidates); - EXPECT_FALSE(found.hit.has_value()); - EXPECT_NE(found.error.find("single atomic segment"), std::string::npos); -} + expect(!found.hit.has_value()); + expect(ne(found.error.find("single atomic segment"), std::string::npos)); +}}; -TEST(PmIndexRoute, LiteralDottedShortNameNeverMatches) { +UTest literal_dotted_short_name_never_matches{"PmIndexRoute/LiteralDottedShortNameNeverMatches", [] { LocalIndex idx("literal"); auto indices = local_map(idx); mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; @@ -152,10 +153,10 @@ TEST(PmIndexRoute, LiteralDottedShortNameNeverMatches) { std::vector literal{ { .namespace_ = "mcpplibs", .shortName = "acme.util" }, }; - EXPECT_FALSE(mcpp::pm::lookup_descriptor(route, literal).hit.has_value()); -} + expect(!(mcpp::pm::lookup_descriptor(route, literal).hit.has_value())); +}}; -TEST(PmIndexRoute, MissInAReadableIndexIsConclusive) { +UTest miss_in_a_readable_index_is_conclusive{"PmIndexRoute/MissInAReadableIndexIsConclusive", [] { LocalIndex idx("conclusive"); auto indices = local_map(idx); mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; @@ -164,11 +165,11 @@ TEST(PmIndexRoute, MissInAReadableIndexIsConclusive) { { .namespace_ = "acme", .shortName = "nope" }, }; auto found = mcpp::pm::lookup_descriptor(route, missing); - EXPECT_FALSE(found.hit.has_value()); - EXPECT_TRUE(found.conclusive); -} + expect(!found.hit.has_value()); + expect(bool(found.conclusive)); +}}; -TEST(PmIndexRoute, MissBehindAnUnreadableIndexIsNotConclusive) { +UTest miss_behind_an_unreadable_index_is_not_conclusive{"PmIndexRoute/MissBehindAnUnreadableIndexIsNotConclusive", [] { mcpp::pm::IndexMap indices; indices["acme"] = mcpp::pm::IndexSpec{ .name = "acme", .url = "https://x/y" }; mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; @@ -177,11 +178,11 @@ TEST(PmIndexRoute, MissBehindAnUnreadableIndexIsNotConclusive) { { .namespace_ = "acme", .shortName = "nope" }, }; auto found = mcpp::pm::lookup_descriptor(route, missing); - EXPECT_FALSE(found.hit.has_value()); - EXPECT_FALSE(found.conclusive); -} + expect(!found.hit.has_value()); + expect(!found.conclusive); +}}; -TEST(PmIndexRoute, WorkspaceMemberReadsRootAnchoredRelativeIndex) { +UTest workspace_member_reads_root_anchored_relative_index{"PmIndexRoute/WorkspaceMemberReadsRootAnchoredRelativeIndex", [] { auto root = std::filesystem::temp_directory_path() / "mcpp-index-route-workspace-relative"; std::filesystem::remove_all(root); @@ -217,7 +218,7 @@ version = "0.1.0" )"; auto indices = mcpp::pm::effective_indices(root / "member"); - ASSERT_TRUE(indices.contains("acme")); + expect(fatal(indices.contains("acme"))); // Assert the CAPABILITY (the member can read the root's index), not the // path SPELLING. The first version of this test compared the stored path @@ -226,14 +227,13 @@ version = "0.1.0" // about whether the index is reachable — which is why it stayed green // while the Windows suite was red. mcpp::pm::IndexRoute route{ &indices, root / "member", nullptr }; - EXPECT_EQ(route.describe("acme"), - "local index 'acme': root present, pkgs present"); + expect(eq(route.describe("acme"), "local index 'acme': root present, pkgs present")); auto selector = mcpp::pm::resolve_dependency_selector("acme.util"); auto found = mcpp::pm::lookup_descriptor(route, selector.candidates); - ASSERT_TRUE(found.hit.has_value()); - EXPECT_EQ(found.hit->coord.namespace_, "acme"); - EXPECT_EQ(found.hit->coord.shortName, "util"); -} + expect(fatal(found.hit.has_value())); + expect(eq(found.hit->coord.namespace_, "acme")); + expect(eq(found.hit->coord.shortName, "util")); +}}; // Why `inherit_workspace_indices` anchors with `lexically_normal` and not // `weakly_canonical`. @@ -251,9 +251,9 @@ version = "0.1.0" // short-name alias — turned out NOT to be the cause of the red Windows suite; // that was a fixture writing an MSYS path into mcpp.toml. The change survives // on this invariant, which is why the invariant is now a test.) -TEST(PmIndexRoute, InheritedIndexStaysInsideTheDeclaredWorkspaceTree) { +UTest inherited_index_stays_inside_the_declared_workspace_tree{"PmIndexRoute/InheritedIndexStaysInsideTheDeclaredWorkspaceTree", [] { #ifdef _WIN32 - GTEST_SKIP() << "directory symlinks need elevation on Windows"; + skip("directory symlinks need elevation on Windows"); #else auto base = std::filesystem::temp_directory_path() / "mcpp-index-route-workspace-symlink"; @@ -292,26 +292,25 @@ version = "0.1.0" )"; std::error_code ec; std::filesystem::create_directory_symlink(real, link, ec); - if (ec) GTEST_SKIP() << "symlinks unavailable here: " << ec.message(); + if (ec) skip([&] { std::ostringstream text; text << "symlinks unavailable here: " << ec.message(); return text.str(); }()); auto indices = mcpp::pm::effective_indices(link / "member"); - ASSERT_TRUE(indices.contains("acme")); + expect(fatal(indices.contains("acme"))); // The declared tree is preserved: the anchored path is still addressed // through `link`, not rewritten to `real`. const auto anchored = indices.at("acme").path.string(); - EXPECT_TRUE(anchored.starts_with(link.string())) + expect(anchored.starts_with(link.string())) << "anchoring relocated the index out of the declared tree: " << anchored; // …and it is still readable through that spelling. Preserving identity is // only correct if the index remains reachable. mcpp::pm::IndexRoute route{ &indices, link / "member", nullptr }; - EXPECT_EQ(route.describe("acme"), - "local index 'acme': root present, pkgs present"); + expect(eq(route.describe("acme"), "local index 'acme': root present, pkgs present")); auto selector = mcpp::pm::resolve_dependency_selector("acme.util"); auto found = mcpp::pm::lookup_descriptor(route, selector.candidates); - ASSERT_TRUE(found.hit.has_value()); - EXPECT_EQ(found.hit->coord.namespace_, "acme"); + expect(fatal(found.hit.has_value())); + expect(eq(found.hit->coord.namespace_, "acme")); #endif -} +}}; diff --git a/tests/unit/test_pm_lock_io.cpp b/tests/unit/test_pm_lock_io.cpp index d0d3c146d..e24cea462 100644 --- a/tests/unit/test_pm_lock_io.cpp +++ b/tests/unit/test_pm_lock_io.cpp @@ -1,110 +1,107 @@ -#include import std; +import mcpplibs.mtest; import mcpp.pm.lock_io; -TEST(PmLockIo, ParseGitBranchWithCommit) { +using namespace mcpplibs::mtest; + +UTest parse_git_branch_with_commit{"PmLockIo/ParseGitBranchWithCommit", [] { auto parsed = mcpp::pm::parse_git_source( "git+https://github.com/user/repo#branch=develop@584894315b7a4fe4d7957d3c29dc4052b8012860"); - ASSERT_TRUE(parsed.has_value()); - EXPECT_EQ(parsed->url, "https://github.com/user/repo"); - EXPECT_EQ(parsed->refKind, "branch"); - EXPECT_EQ(parsed->ref, "develop"); - ASSERT_TRUE(parsed->resolvedCommit.has_value()); - EXPECT_EQ(parsed->resolvedCommit.value(), - "584894315b7a4fe4d7957d3c29dc4052b8012860"); -} - -TEST(PmLockIo, ParseGitBranchWithoutCommit) { + expect(fatal(parsed.has_value())); + expect(eq(parsed->url, "https://github.com/user/repo")); + expect(eq(parsed->refKind, "branch")); + expect(eq(parsed->ref, "develop")); + expect(fatal(parsed->resolvedCommit.has_value())); + expect(eq(parsed->resolvedCommit.value(), "584894315b7a4fe4d7957d3c29dc4052b8012860")); +}}; + +UTest parse_git_branch_without_commit{"PmLockIo/ParseGitBranchWithoutCommit", [] { auto parsed = mcpp::pm::parse_git_source( "git+https://github.com/user/repo#branch=develop"); - ASSERT_TRUE(parsed.has_value()); - EXPECT_EQ(parsed->url, "https://github.com/user/repo"); - EXPECT_EQ(parsed->refKind, "branch"); - EXPECT_EQ(parsed->ref, "develop"); - EXPECT_FALSE(parsed->resolvedCommit.has_value()); -} - -TEST(PmLockIo, ParseGitTag) { + expect(fatal(parsed.has_value())); + expect(eq(parsed->url, "https://github.com/user/repo")); + expect(eq(parsed->refKind, "branch")); + expect(eq(parsed->ref, "develop")); + expect(!(parsed->resolvedCommit.has_value())); +}}; + +UTest parse_git_tag{"PmLockIo/ParseGitTag", [] { auto parsed = mcpp::pm::parse_git_source( "git+https://github.com/user/repo#tag=v1.0.0"); - ASSERT_TRUE(parsed.has_value()); - EXPECT_EQ(parsed->url, "https://github.com/user/repo"); - EXPECT_EQ(parsed->refKind, "tag"); - EXPECT_EQ(parsed->ref, "v1.0.0"); - EXPECT_FALSE(parsed->resolvedCommit.has_value()); -} - -TEST(PmLockIo, ParseGitRev) { + expect(fatal(parsed.has_value())); + expect(eq(parsed->url, "https://github.com/user/repo")); + expect(eq(parsed->refKind, "tag")); + expect(eq(parsed->ref, "v1.0.0")); + expect(!(parsed->resolvedCommit.has_value())); +}}; + +UTest parse_git_rev{"PmLockIo/ParseGitRev", [] { auto parsed = mcpp::pm::parse_git_source( "git+https://github.com/user/repo#rev=584894315b7a4fe4d7957d3c29dc4052b8012860"); - ASSERT_TRUE(parsed.has_value()); - EXPECT_EQ(parsed->url, "https://github.com/user/repo"); - EXPECT_EQ(parsed->refKind, "rev"); - EXPECT_EQ(parsed->ref, "584894315b7a4fe4d7957d3c29dc4052b8012860"); - EXPECT_FALSE(parsed->resolvedCommit.has_value()); -} + expect(fatal(parsed.has_value())); + expect(eq(parsed->url, "https://github.com/user/repo")); + expect(eq(parsed->refKind, "rev")); + expect(eq(parsed->ref, "584894315b7a4fe4d7957d3c29dc4052b8012860")); + expect(!(parsed->resolvedCommit.has_value())); +}}; // A branch name may legitimately contain '@' — the commit is whatever follows // the LAST one, matching how prepare.cppm writes `ref + "@" + commit`. -TEST(PmLockIo, ParseGitBranchNameContainingAt) { +UTest parse_git_branch_name_containing_at{"PmLockIo/ParseGitBranchNameContainingAt", [] { auto parsed = mcpp::pm::parse_git_source( "git+https://github.com/user/repo#branch=feat@v2@584894315b7a4fe4d7957d3c29dc4052b8012860"); - ASSERT_TRUE(parsed.has_value()); - EXPECT_EQ(parsed->ref, "feat@v2"); - ASSERT_TRUE(parsed->resolvedCommit.has_value()); - EXPECT_EQ(parsed->resolvedCommit.value(), - "584894315b7a4fe4d7957d3c29dc4052b8012860"); -} + expect(fatal(parsed.has_value())); + expect(eq(parsed->ref, "feat@v2")); + expect(fatal(parsed->resolvedCommit.has_value())); + expect(eq(parsed->resolvedCommit.value(), "584894315b7a4fe4d7957d3c29dc4052b8012860")); +}}; // `branch=foo@` records no commit; an empty string would make the anchor claim // a pin it does not have. -TEST(PmLockIo, ParseGitBranchWithEmptyCommit) { +UTest parse_git_branch_with_empty_commit{"PmLockIo/ParseGitBranchWithEmptyCommit", [] { auto parsed = mcpp::pm::parse_git_source( "git+https://github.com/user/repo#branch=develop@"); - ASSERT_TRUE(parsed.has_value()); - EXPECT_EQ(parsed->ref, "develop"); - EXPECT_FALSE(parsed->resolvedCommit.has_value()); -} + expect(fatal(parsed.has_value())); + expect(eq(parsed->ref, "develop")); + expect(!(parsed->resolvedCommit.has_value())); +}}; // Only branch entries carry `@`, so a tag or rev whose name contains // '@' must survive verbatim rather than be split into ref + commit. -TEST(PmLockIo, ParseGitTagNameContainingAtIsNotSplit) { +UTest parse_git_tag_name_containing_at_is_not_split{"PmLockIo/ParseGitTagNameContainingAtIsNotSplit", [] { auto tag = mcpp::pm::parse_git_source( "git+https://github.com/user/repo#tag=v1.0@rc1"); - ASSERT_TRUE(tag.has_value()); - EXPECT_EQ(tag->ref, "v1.0@rc1"); - EXPECT_FALSE(tag->resolvedCommit.has_value()); + expect(fatal(tag.has_value())); + expect(eq(tag->ref, "v1.0@rc1")); + expect(!(tag->resolvedCommit.has_value())); auto rev = mcpp::pm::parse_git_source( "git+https://github.com/user/repo#rev=abc123@def"); - ASSERT_TRUE(rev.has_value()); - EXPECT_EQ(rev->ref, "abc123@def"); - EXPECT_FALSE(rev->resolvedCommit.has_value()); -} + expect(fatal(rev.has_value())); + expect(eq(rev->ref, "abc123@def")); + expect(!(rev->resolvedCommit.has_value())); +}}; // An scp-like URL puts '@' before the '#', so the URL half must not be // disturbed by the ref-side split. -TEST(PmLockIo, ParseGitScpLikeUrl) { +UTest parse_git_scp_like_url{"PmLockIo/ParseGitScpLikeUrl", [] { auto parsed = mcpp::pm::parse_git_source( "git+git@github.com:user/repo.git#branch=develop@584894315b7a4fe4d7957d3c29dc4052b8012860"); - ASSERT_TRUE(parsed.has_value()); - EXPECT_EQ(parsed->url, "git@github.com:user/repo.git"); - EXPECT_EQ(parsed->ref, "develop"); - ASSERT_TRUE(parsed->resolvedCommit.has_value()); - EXPECT_EQ(parsed->resolvedCommit.value(), - "584894315b7a4fe4d7957d3c29dc4052b8012860"); -} - -TEST(PmLockIo, ParseNonGitSourceReturnsNullopt) { - EXPECT_FALSE(mcpp::pm::parse_git_source("index+mcpplibs@1.0.0").has_value()); - EXPECT_FALSE(mcpp::pm::parse_git_source("").has_value()); - EXPECT_FALSE( - mcpp::pm::parse_git_source("https://github.com/user/repo").has_value()); - EXPECT_FALSE(mcpp::pm::parse_git_source("git+https://host/repo").has_value()); - EXPECT_FALSE( - mcpp::pm::parse_git_source("git+https://host/repo#bad").has_value()); -} + expect(fatal(parsed.has_value())); + expect(eq(parsed->url, "git@github.com:user/repo.git")); + expect(eq(parsed->ref, "develop")); + expect(fatal(parsed->resolvedCommit.has_value())); + expect(eq(parsed->resolvedCommit.value(), "584894315b7a4fe4d7957d3c29dc4052b8012860")); +}}; + +UTest parse_non_git_source_returns_nullopt{"PmLockIo/ParseNonGitSourceReturnsNullopt", [] { + expect(!(mcpp::pm::parse_git_source("index+mcpplibs@1.0.0").has_value())); + expect(!(mcpp::pm::parse_git_source("").has_value())); + expect(!(mcpp::pm::parse_git_source("https://github.com/user/repo").has_value())); + expect(!(mcpp::pm::parse_git_source("git+https://host/repo").has_value())); + expect(!(mcpp::pm::parse_git_source("git+https://host/repo#bad").has_value())); +}}; // The lock digest MUST be the same on every host. It used `std::hash`, // whose output is implementation-defined — MSVC implements it as FNV-1a, while @@ -113,12 +110,10 @@ TEST(PmLockIo, ParseNonGitSourceReturnsNullopt) { // clean checkout showed a lock diff after every command, and the `fnv1a:` prefix // was only true on MSVC. These vectors are the cross-platform contract: a // host-dependent hash turns them red on one of the two platforms. -TEST(PmLockIo, IndexPackageDigestIsFnv1aOnEveryHost) { - EXPECT_EQ(mcpp::pm::index_package_digest("compat", "compat.catch2", "2.13.10"), - "fnv1a:50719400df192025"); - EXPECT_EQ(mcpp::pm::index_package_digest("acme", "acme.gadget", "2.1.0"), - "fnv1a:2adea846f70078bc"); -} +UTest index_package_digest_is_fnv1a_on_every_host{"PmLockIo/IndexPackageDigestIsFnv1aOnEveryHost", [] { + expect(eq(mcpp::pm::index_package_digest("compat", "compat.catch2", "2.13.10"), "fnv1a:50719400df192025")); + expect(eq(mcpp::pm::index_package_digest("acme", "acme.gadget", "2.1.0"), "fnv1a:2adea846f70078bc")); +}}; namespace { @@ -139,7 +134,7 @@ std::string lock_bytes(const std::filesystem::path& file) { } } -TEST(PmLockIo, UnchangedLfAndCrlfLocksKeepTheirBytesAndTimestamp) { +UTest unchanged_lf_and_crlf_locks_keep_their_bytes_and_timestamp{"PmLockIo/UnchangedLfAndCrlfLocksKeepTheirBytesAndTimestamp", [] { LockDirectory t; const auto file = t.root / "mcpp.lock"; mcpp::pm::Lockfile lock; @@ -155,68 +150,68 @@ TEST(PmLockIo, UnchangedLfAndCrlfLocksKeepTheirBytesAndTimestamp) { const auto old = std::filesystem::file_time_type::clock::now() - std::chrono::hours(24); std::filesystem::last_write_time(file, old); const auto recorded = std::filesystem::last_write_time(file); - ASSERT_TRUE(mcpp::pm::write(lock, file)); - EXPECT_EQ(lock_bytes(file), bytes); - EXPECT_EQ(std::filesystem::last_write_time(file), recorded); + expect(fatal(bool(mcpp::pm::write(lock, file)))); + expect(eq(lock_bytes(file), bytes)); + expect(eq(std::filesystem::last_write_time(file), recorded)); } -} +}}; -TEST(PmLockIo, CreatesMissingParentsAndPublishesChangedResolution) { +UTest creates_missing_parents_and_publishes_changed_resolution{"PmLockIo/CreatesMissingParentsAndPublishesChangedResolution", [] { LockDirectory t; const auto file = t.root / "nested" / "mcpp.lock"; mcpp::pm::Lockfile lock; lock.packages.push_back({"fixture", "example", "1.0.0", "path", "digest"}); - ASSERT_TRUE(mcpp::pm::write(lock, file)); + expect(fatal(bool(mcpp::pm::write(lock, file)))); lock.packages[0].version = "2.0.0"; - ASSERT_TRUE(mcpp::pm::write(lock, file)); + expect(fatal(bool(mcpp::pm::write(lock, file)))); auto loaded = mcpp::pm::load(file); - ASSERT_TRUE(loaded); - ASSERT_EQ(loaded->packages.size(), 1u); - EXPECT_EQ(loaded->packages[0].version, "2.0.0"); -} + expect(fatal(bool(loaded))); + expect(fatal(eq(loaded->packages.size(), 1u))); + expect(eq(loaded->packages[0].version, "2.0.0")); +}}; -TEST(PmLockIo, UnchangedReadOnlyLockDoesNotNeedWriteAccess) { +UTest unchanged_read_only_lock_does_not_need_write_access{"PmLockIo/UnchangedReadOnlyLockDoesNotNeedWriteAccess", [] { LockDirectory t; const auto file = t.root / "mcpp.lock"; mcpp::pm::Lockfile lock; - ASSERT_TRUE(mcpp::pm::write(lock, file)); + expect(fatal(bool(mcpp::pm::write(lock, file)))); std::filesystem::permissions(file, std::filesystem::perms::owner_read); const auto recorded = std::filesystem::last_write_time(file); - EXPECT_TRUE(mcpp::pm::write(lock, file)); - EXPECT_EQ(std::filesystem::last_write_time(file), recorded); -} + expect(bool(mcpp::pm::write(lock, file))); + expect(eq(std::filesystem::last_write_time(file), recorded)); +}}; -TEST(PmLockIo, AnUnreadableFileOrUncreatableParentReturnsAnError) { +UTest an_unreadable_file_or_uncreatable_parent_returns_an_error{"PmLockIo/AnUnreadableFileOrUncreatableParentReturnsAnError", [] { LockDirectory t; const auto blocker = t.root / "file"; { std::ofstream out(blocker); out << "not a directory"; } mcpp::pm::Lockfile lock; - EXPECT_FALSE(mcpp::pm::write(lock, blocker / "mcpp.lock")); - EXPECT_FALSE(mcpp::pm::write(lock, t.root)); -} + expect(!mcpp::pm::write(lock, blocker / "mcpp.lock")); + expect(!mcpp::pm::write(lock, t.root)); +}}; -TEST(PmLockIo, UpdatingALinkedLockKeepsTheLinkAndUpdatesItsTarget) { +UTest updating_a_linked_lock_keeps_the_link_and_updates_its_target{"PmLockIo/UpdatingALinkedLockKeepsTheLinkAndUpdatesItsTarget", [] { LockDirectory t; const auto target = t.root / "target.lock"; const auto link = t.root / "mcpp.lock"; mcpp::pm::Lockfile lock; - ASSERT_TRUE(mcpp::pm::write(lock, target)); + expect(fatal(bool(mcpp::pm::write(lock, target)))); std::error_code ec; std::filesystem::create_symlink(target, link, ec); - if (ec) GTEST_SKIP() << "this host does not permit file symlinks: " << ec.message(); + if (ec) skip([&] { std::ostringstream text; text << "this host does not permit file symlinks: " << ec.message(); return text.str(); }()); lock.packages.push_back({"fixture", "example", "2.0.0", "path", "digest"}); - ASSERT_TRUE(mcpp::pm::write(lock, link)); - EXPECT_TRUE(std::filesystem::is_symlink(link)); + expect(fatal(bool(mcpp::pm::write(lock, link)))); + expect(std::filesystem::is_symlink(link)); auto loaded = mcpp::pm::load(target); - ASSERT_TRUE(loaded); - ASSERT_EQ(loaded->packages.size(), 1u); - EXPECT_EQ(loaded->packages[0].version, "2.0.0"); -} + expect(fatal(bool(loaded))); + expect(fatal(eq(loaded->packages.size(), 1u))); + expect(eq(loaded->packages[0].version, "2.0.0")); +}}; // An entry names the configurations that resolve it, and reads back as written // (SPEC-013 §15.1); a lock written before the field reads with none. -TEST(PmLockIo, TargetsRoundTrip) { +UTest targets_round_trip{"PmLockIo/TargetsRoundTrip", [] { const auto dir = std::filesystem::temp_directory_path() / std::format("mcpp-lock-{}", std::chrono::steady_clock::now().time_since_epoch().count()); std::filesystem::create_directories(dir); @@ -231,16 +226,16 @@ TEST(PmLockIo, TargetsRoundTrip) { p.origin = "platform"; p.targets = { "aarch64-openkal", "x86_64-openkal" }; lock.packages.push_back(p); - ASSERT_TRUE(mcpp::pm::write(lock, dir / "mcpp.lock")); + expect(fatal(bool(mcpp::pm::write(lock, dir / "mcpp.lock")))); auto back = mcpp::pm::load(dir / "mcpp.lock"); - ASSERT_TRUE(back); - ASSERT_EQ(back->packages.size(), 1u); - EXPECT_EQ(back->packages[0].targets, p.targets); - EXPECT_EQ(back->packages[0].origin, "platform"); + expect(fatal(bool(back))); + expect(fatal(eq(back->packages.size(), 1u))); + expect(eq(back->packages[0].targets, p.targets)); + expect(eq(back->packages[0].origin, "platform")); std::ofstream(dir / "old.lock") << "version = 2\n\n[package.\"x\"]\nnamespace = \"mcpplibs\"\nversion = \"1\"\n"; auto old = mcpp::pm::load(dir / "old.lock"); - ASSERT_TRUE(old); - EXPECT_TRUE(old->packages.at(0).targets.empty()); + expect(fatal(bool(old))); + expect(old->packages.at(0).targets.empty()); std::error_code ec; std::filesystem::remove_all(dir, ec); -} +}}; diff --git a/tests/unit/test_pm_package_fetcher.cpp b/tests/unit/test_pm_package_fetcher.cpp index dc0b32dc8..d3795c17e 100644 --- a/tests/unit/test_pm_package_fetcher.cpp +++ b/tests/unit/test_pm_package_fetcher.cpp @@ -1,9 +1,12 @@ -#include import std; +import mcpplibs.mtest; +import mcpplibs.os.stdio; import mcpp.pm.package_fetcher; import mcpp.pm.index_contract; +using namespace mcpplibs::mtest; + // Regression for the compat.zlib vs upstream bare zlib.lua collision. // // read_xpkg_lua_from_project_data scans every index dir under the project's @@ -49,7 +52,7 @@ package = { } // namespace -TEST(PmPackageFetcher, ResolvesCompatZlibNotForeignBareZlib) { +UTest resolves_compat_zlib_not_foreign_bare_zlib{"PmPackageFetcher/ResolvesCompatZlibNotForeignBareZlib", [] { auto project = make_tempdir("mcpp-fetcher-collision"); auto dataRoot = project / ".mcpp" / "data"; @@ -63,16 +66,16 @@ TEST(PmPackageFetcher, ResolvesCompatZlibNotForeignBareZlib) { auto lua = mcpp::pm::Fetcher::read_xpkg_lua_from_project_data( project, "compat", "zlib"); - ASSERT_TRUE(lua.has_value()); - EXPECT_NE(lua->find("compat.zlib"), std::string::npos) + expect(fatal(lua.has_value())); + expect(ne(lua->find("compat.zlib"), std::string::npos)) << "should return the compat-namespaced descriptor"; - EXPECT_NE(lua->find("mcpp"), std::string::npos) + expect(ne(lua->find("mcpp"), std::string::npos)) << "the resolved descriptor must carry the mcpp block"; std::filesystem::remove_all(project); -} +}}; -TEST(PmPackageFetcher, ForeignBareZlibAloneDoesNotSatisfyCompatRequest) { +UTest foreign_bare_zlib_alone_does_not_satisfy_compat_request{"PmPackageFetcher/ForeignBareZlibAloneDoesNotSatisfyCompatRequest", [] { auto project = make_tempdir("mcpp-fetcher-nomatch"); auto dataRoot = project / ".mcpp" / "data"; @@ -84,12 +87,12 @@ TEST(PmPackageFetcher, ForeignBareZlibAloneDoesNotSatisfyCompatRequest) { auto lua = mcpp::pm::Fetcher::read_xpkg_lua_from_project_data( project, "compat", "zlib"); - EXPECT_FALSE(lua.has_value()); + expect(!lua.has_value()); std::filesystem::remove_all(project); -} +}}; -TEST(PmPackageFetcher, DefaultNamespaceCannotResolveCompatDescriptor) { +UTest default_namespace_cannot_resolve_compat_descriptor{"PmPackageFetcher/DefaultNamespaceCannotResolveCompatDescriptor", [] { auto project = make_tempdir("mcpp-fetcher-compat-alias"); auto dataRoot = project / ".mcpp" / "data"; @@ -106,17 +109,17 @@ package = { auto defaultLua = mcpp::pm::Fetcher::read_xpkg_lua_from_project_data( project, "mcpplibs", "gtest"); - EXPECT_FALSE(defaultLua.has_value()); + expect(!defaultLua.has_value()); auto explicitLua = mcpp::pm::Fetcher::read_xpkg_lua_from_project_data( project, "compat", "gtest"); - ASSERT_TRUE(explicitLua.has_value()); - EXPECT_NE(explicitLua->find("compat.gtest"), std::string::npos); + expect(fatal(explicitLua.has_value())); + expect(ne(explicitLua->find("compat.gtest"), std::string::npos)); std::filesystem::remove_all(project); -} +}}; -TEST(PmPackageFetcher, LocalPathIndexAttributesOwnNamespaceToNoNsDescriptor) { +UTest local_path_index_attributes_own_namespace_to_no_ns_descriptor{"PmPackageFetcher/LocalPathIndexAttributesOwnNamespaceToNoNsDescriptor", [] { // e2e 49/51: a `[indices]` path index `local-dev` whose descriptor declares // only name="tinycfg" (no namespace field). The namespace is owned by the // index, so a `(local-dev, tinycfg)` request must match via index-owned @@ -126,15 +129,15 @@ TEST(PmPackageFetcher, LocalPathIndexAttributesOwnNamespaceToNoNsDescriptor) { R"(package = { name = "tinycfg", version = "1.0.0", mcpp = { sources = {"*.c"} } })"); auto lua = mcpp::pm::Fetcher::read_xpkg_lua_from_path(index, "local-dev", "tinycfg"); - ASSERT_TRUE(lua.has_value()) << "no-namespace descriptor must inherit the index namespace"; - EXPECT_NE(lua->find("tinycfg"), std::string::npos); + expect(fatal(lua.has_value())) << "no-namespace descriptor must inherit the index namespace"; + expect(ne(lua->find("tinycfg"), std::string::npos)); // A short name the index does not contain still resolves to nothing. auto miss = mcpp::pm::Fetcher::read_xpkg_lua_from_path(index, "local-dev", "absent"); - EXPECT_FALSE(miss.has_value()); + expect(!miss.has_value()); std::filesystem::remove_all(index); -} +}}; // Coverage gap closed: a custom-namespace descriptor filed under a NON-canonical // filename must still resolve for a qualified request, because identity is the @@ -148,7 +151,7 @@ TEST(PmPackageFetcher, LocalPathIndexAttributesOwnNamespaceToNoNsDescriptor) { // `read_xpkg_lua*` already keys on declared identity, so this asserts the READ // layer is correct and the production failure is isolated to candidate SELECTION // (`selectDependencyCandidate`'s canonical-filename-only strict reader). -TEST(PmPackageFetcher, ResolvesCustomNamespaceDescriptorUnderNonCanonicalFilename) { +UTest resolves_custom_namespace_descriptor_under_non_canonical_filename{"PmPackageFetcher/ResolvesCustomNamespaceDescriptorUnderNonCanonicalFilename", [] { auto project = make_tempdir("mcpp-noncanonical-filename"); auto dataRoot = project / ".mcpp" / "data"; @@ -165,18 +168,18 @@ TEST(PmPackageFetcher, ResolvesCustomNamespaceDescriptorUnderNonCanonicalFilenam // Qualified request for the custom namespace must resolve, filename be damned. auto hit = mcpp::pm::Fetcher::read_xpkg_lua_from_project_data( project, "aimol", "tensorvia-cpu"); - ASSERT_TRUE(hit.has_value()) + expect(fatal(hit.has_value())) << "declared (aimol, tensorvia-cpu) must resolve regardless of filename"; - EXPECT_NE(hit->find("tensorvia-cpu"), std::string::npos); + expect(ne(hit->find("tensorvia-cpu"), std::string::npos)); // A foreign namespace for the same short name must NOT match it. auto wrongNs = mcpp::pm::Fetcher::read_xpkg_lua_from_project_data( project, "mcpplibs", "tensorvia-cpu"); - EXPECT_FALSE(wrongNs.has_value()) + expect(!wrongNs.has_value()) << "the descriptor is (aimol, …), so a (mcpplibs, …) request must miss"; std::filesystem::remove_all(project); -} +}}; // ─── #238: install_packages failure diagnostics ───────────────────── // @@ -186,7 +189,7 @@ TEST(PmPackageFetcher, ResolvesCustomNamespaceDescriptorUnderNonCanonicalFilenam // .xlings.json index_repos reader so the enriched, actionable error shape is // locked without needing a live (failing) multi-repo xlings. -TEST(PmPackageFetcher, InstallFailureDiagnosticFlagsMultiRepoGap) { +UTest install_failure_diagnostic_flags_multi_repo_gap{"PmPackageFetcher/InstallFailureDiagnosticFlagsMultiRepoGap", [] { std::vector repos = { "mcpplibs -> https://example/mcpplibs", "myindex -> https://example/myindex", @@ -195,44 +198,44 @@ TEST(PmPackageFetcher, InstallFailureDiagnosticFlagsMultiRepoGap) { "acme.widget@1.2.3", 1, repos, /*childError=*/""); // Names the target + exit code. - EXPECT_NE(msg.find("acme.widget@1.2.3"), std::string::npos); - EXPECT_NE(msg.find("exit 1"), std::string::npos); + expect(ne(msg.find("acme.widget@1.2.3"), std::string::npos)); + expect(ne(msg.find("exit 1"), std::string::npos)); // Enumerates the configured repos (count + identities). - EXPECT_NE(msg.find("2 index repos"), std::string::npos); - EXPECT_NE(msg.find("mcpplibs"), std::string::npos); - EXPECT_NE(msg.find("myindex"), std::string::npos); + expect(ne(msg.find("2 index repos"), std::string::npos)); + expect(ne(msg.find("mcpplibs"), std::string::npos)); + expect(ne(msg.find("myindex"), std::string::npos)); // Flags the known ≥2-repo xlings gap and links the tracking issue. - EXPECT_NE(msg.find("#238"), std::string::npos); - EXPECT_NE(msg.find("openxlings/xlings"), std::string::npos); + expect(ne(msg.find("#238"), std::string::npos)); + expect(ne(msg.find("openxlings/xlings"), std::string::npos)); // No child error → points at MCPP_VERBOSE for the raw output. - EXPECT_NE(msg.find("MCPP_VERBOSE"), std::string::npos); -} + expect(ne(msg.find("MCPP_VERBOSE"), std::string::npos)); +}}; -TEST(PmPackageFetcher, InstallFailureDiagnosticSingleRepoNoGapFlag) { +UTest install_failure_diagnostic_single_repo_no_gap_flag{"PmPackageFetcher/InstallFailureDiagnosticSingleRepoNoGapFlag", [] { std::vector repos = { "mcpplibs" }; auto msg = mcpp::pm::format_install_failure_diagnostic( "acme.widget@1.2.3", 1, repos, /*childError=*/""); - EXPECT_NE(msg.find("1 index repo"), std::string::npos); + expect(ne(msg.find("1 index repo"), std::string::npos)); // A single repo is NOT the #238 failure mode; do not misattribute it. - EXPECT_EQ(msg.find("known xlings resolution gap"), std::string::npos); - EXPECT_EQ(msg.find("#238"), std::string::npos); -} + expect(eq(msg.find("known xlings resolution gap"), std::string::npos)); + expect(eq(msg.find("#238"), std::string::npos)); +}}; -TEST(PmPackageFetcher, InstallFailureDiagnosticPreservesChildError) { +UTest install_failure_diagnostic_preserves_child_error{"PmPackageFetcher/InstallFailureDiagnosticPreservesChildError", [] { std::vector repos = { "a", "b" }; auto msg = mcpp::pm::format_install_failure_diagnostic( "acme.widget@1.2.3", 1, repos, /*childError=*/"[error] descriptor not found in index"); // Child-emitted text must be surfaced, not swallowed. - EXPECT_NE(msg.find("descriptor not found in index"), std::string::npos); - EXPECT_NE(msg.find("xlings reported"), std::string::npos); + expect(ne(msg.find("descriptor not found in index"), std::string::npos)); + expect(ne(msg.find("xlings reported"), std::string::npos)); // With a real child error we don't fall back to the MCPP_VERBOSE nudge. - EXPECT_EQ(msg.find("MCPP_VERBOSE"), std::string::npos); -} + expect(eq(msg.find("MCPP_VERBOSE"), std::string::npos)); +}}; -TEST(PmPackageFetcher, ReadSeededIndexReposParsesXlingsJson) { +UTest read_seeded_index_repos_parses_xlings_json{"PmPackageFetcher/ReadSeededIndexReposParsesXlingsJson", [] { auto project = make_tempdir("mcpp-238-xlings-json"); auto xjson = project / ".xlings.json"; write_file(xjson, R"({ @@ -244,19 +247,19 @@ TEST(PmPackageFetcher, ReadSeededIndexReposParsesXlingsJson) { })"); auto repos = mcpp::pm::read_seeded_index_repos(xjson); - ASSERT_EQ(repos.size(), 2u); - EXPECT_NE(repos[0].find("mcpplibs"), std::string::npos); - EXPECT_NE(repos[0].find("https://example/mcpplibs"), std::string::npos); - EXPECT_NE(repos[1].find("myindex"), std::string::npos); + expect(fatal(eq(repos.size(), 2u))); + expect(ne(repos[0].find("mcpplibs"), std::string::npos)); + expect(ne(repos[0].find("https://example/mcpplibs"), std::string::npos)); + expect(ne(repos[1].find("myindex"), std::string::npos)); // Missing file → empty (caller degrades to "unknown configuration"). auto none = mcpp::pm::read_seeded_index_repos(project / "does-not-exist.json"); - EXPECT_TRUE(none.empty()); + expect(none.empty()); std::filesystem::remove_all(project); -} +}}; -TEST(PmPackageFetcher, ReadSeededIndexReposToleratesArtifactFields) { +UTest read_seeded_index_repos_tolerates_artifact_fields{"PmPackageFetcher/ReadSeededIndexReposToleratesArtifactFields", [] { // #269: entries seeded with per-repo artifact/source keys (xlings >= // 0.4.68) must still yield name+url — regression lock for the hand- // rolled index_repos reader. @@ -270,43 +273,42 @@ TEST(PmPackageFetcher, ReadSeededIndexReposToleratesArtifactFields) { })"); auto repos = mcpp::pm::read_seeded_index_repos(xjson); - ASSERT_EQ(repos.size(), 1u); - EXPECT_NE(repos[0].find("mcpplibs"), std::string::npos); - EXPECT_NE(repos[0].find("https://github.com/mcpplibs/mcpp-index.git"), - std::string::npos); + expect(fatal(eq(repos.size(), 1u))); + expect(ne(repos[0].find("mcpplibs"), std::string::npos)); + expect(ne(repos[0].find("https://github.com/mcpplibs/mcpp-index.git"), std::string::npos)); std::filesystem::remove_all(project); -} +}}; // A tree whose index.toml requires a newer mcpp answers no lookup, and the // read prints nothing. The fact is recorded for the message of a run that then // fails (unusable_index_hint), which carries the E0006 text itself; a run that // resolves every package elsewhere has no error to report. Before this, the // read site printed the E0006 text as `error:` at the start of such runs. -TEST(PackageFetcher, AnIndexThatRequiresANewerMcppIsRecordedNotPrinted) { +UTest an_index_that_requires_a_newer_mcpp_is_recorded_not_printed{"PackageFetcher/AnIndexThatRequiresANewerMcppIsRecordedNotPrinted", [] { auto root = make_tempdir("mcpp-floor-read"); write_file(root / "index.toml", "[index]\nspec = \"1\"\nmin_mcpp = \"9999.9.9.9\"\n"); write_file(root / "pkgs" / "f" / "floorpkg.lua", "package = { spec = \"1\", name = \"floorpkg\", type = \"package\" }\n"); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); auto first = mcpp::pm::Fetcher::read_xpkg_lua_from_path(root, "floorns", "floorpkg"); auto second = mcpp::pm::Fetcher::read_xpkg_lua_from_path(root, "floorns", "floorpkg"); - auto err = testing::internal::GetCapturedStderr(); + auto err = stderrCapture.text(); - EXPECT_FALSE(first.has_value()); - EXPECT_FALSE(second.has_value()) << "a tree recorded as unusable answers no later lookup"; - EXPECT_TRUE(err.empty()) << err; - EXPECT_TRUE(mcpp::pm::index_marked_unusable(root)); + expect(!first.has_value()); + expect(!second.has_value()) << "a tree recorded as unusable answers no later lookup"; + expect(err.empty()) << err; + expect(bool(mcpp::pm::index_marked_unusable(root))); auto hint = mcpp::pm::unusable_index_hint(); - EXPECT_NE(hint.find("E0006"), std::string::npos) << hint; - EXPECT_NE(hint.find("9999.9.9.9"), std::string::npos) << hint; - EXPECT_EQ(hint.find("error above"), std::string::npos) << hint; + expect(ne(hint.find("E0006"), std::string::npos)) << hint; + expect(ne(hint.find("9999.9.9.9"), std::string::npos)) << hint; + expect(eq(hint.find("error above"), std::string::npos)) << hint; mcpp::pm::reset_unusable_indexes_for_test(); std::filesystem::remove_all(root); -} +}}; // ─── What a caller's intent does with a payload (design 2026-10-17 D3) ─── // @@ -314,36 +316,36 @@ TEST(PackageFetcher, AnIndexThatRequiresANewerMcppIsRecordedNotPrinted) { // revision 1 with no floor -- complete, usable, not current. A build USES it; // only `mcpp toolchain install` replaces it. 2026.10.16.x replaced it inside // the build (and, with one dangling record in the home, failed every run). -TEST(PmPayloadIntent, AnOlderPackagingIsUsedByABuildAndReplacedOnlyWhenProvisioned) { +UTest an_older_packaging_is_used_by_a_build_and_replaced_only_when_provisioned{"PmPayloadIntent/AnOlderPackagingIsUsedByABuildAndReplacedOnlyWhenProvisioned", [] { using mcpp::pm::PayloadAction; using mcpp::pm::PayloadIntent; using mcpp::pm::payload_action; // present, usable, current - EXPECT_EQ(payload_action(PayloadIntent::Use, true, true, false), PayloadAction::Take); - EXPECT_EQ(payload_action(PayloadIntent::Locate, true, true, false), PayloadAction::Take); - EXPECT_EQ(payload_action(PayloadIntent::Provision, true, true, false), PayloadAction::Replace); - EXPECT_EQ(payload_action(PayloadIntent::Provision, true, true, true), PayloadAction::Take); -} + expect(eq(payload_action(PayloadIntent::Use, true, true, false), PayloadAction::Take)); + expect(eq(payload_action(PayloadIntent::Locate, true, true, false), PayloadAction::Take)); + expect(eq(payload_action(PayloadIntent::Provision, true, true, false), PayloadAction::Replace)); + expect(eq(payload_action(PayloadIntent::Provision, true, true, true), PayloadAction::Take)); +}}; // Below the recipe's floor, or incomplete: not usable, so even a build // replaces it -- the author said the old packaging must go. -TEST(PmPayloadIntent, AnUnusablePayloadIsReplacedByEveryIntentThatMayInstall) { +UTest an_unusable_payload_is_replaced_by_every_intent_that_may_install{"PmPayloadIntent/AnUnusablePayloadIsReplacedByEveryIntentThatMayInstall", [] { using mcpp::pm::PayloadAction; using mcpp::pm::PayloadIntent; using mcpp::pm::payload_action; - EXPECT_EQ(payload_action(PayloadIntent::Use, true, false, false), PayloadAction::Replace); - EXPECT_EQ(payload_action(PayloadIntent::Provision, true, false, false), PayloadAction::Replace); - EXPECT_EQ(payload_action(PayloadIntent::Locate, true, false, false), PayloadAction::Refuse); -} + expect(eq(payload_action(PayloadIntent::Use, true, false, false), PayloadAction::Replace)); + expect(eq(payload_action(PayloadIntent::Provision, true, false, false), PayloadAction::Replace)); + expect(eq(payload_action(PayloadIntent::Locate, true, false, false), PayloadAction::Refuse)); +}}; -TEST(PmPayloadIntent, NothingThereIsInstalledUnlessOnlyLocated) { +UTest nothing_there_is_installed_unless_only_located{"PmPayloadIntent/NothingThereIsInstalledUnlessOnlyLocated", [] { using mcpp::pm::PayloadAction; using mcpp::pm::PayloadIntent; using mcpp::pm::payload_action; - EXPECT_EQ(payload_action(PayloadIntent::Use, false, false, false), PayloadAction::Install); - EXPECT_EQ(payload_action(PayloadIntent::Provision, false, false, false), PayloadAction::Install); - EXPECT_EQ(payload_action(PayloadIntent::Locate, false, false, false), PayloadAction::Refuse); -} + expect(eq(payload_action(PayloadIntent::Use, false, false, false), PayloadAction::Install)); + expect(eq(payload_action(PayloadIntent::Provision, false, false, false), PayloadAction::Install)); + expect(eq(payload_action(PayloadIntent::Locate, false, false, false), PayloadAction::Refuse)); +}}; // ─── What a failed install calls for (D5) ─── // @@ -352,21 +354,21 @@ TEST(PmPayloadIntent, NothingThereIsInstalledUnlessOnlyLocated) { // descriptor that was local, retried the same doomed replacement through the // command line, and told the user to retry. A deterministic refusal is said // once; only a package the provider could not find refreshes the index. -TEST(PmInstallFailurePolicy, ADeterministicRefusalIsNeitherRefreshedNorRetried) { +UTest a_deterministic_refusal_is_neither_refreshed_nor_retried{"PmInstallFailurePolicy/ADeterministicRefusalIsNeitherRefreshedNorRetried", [] { for (std::string_view code : {"E_INTERNAL", "E_PERMISSION", "E_DISK_FULL", "E_CANCELLED"}) { const auto p = mcpp::pm::install_failure_policy(code); - EXPECT_FALSE(p.refreshIndex) << code; - EXPECT_FALSE(p.retry) << code; + expect(!p.refreshIndex) << code; + expect(!p.retry) << code; } -} - -TEST(PmInstallFailurePolicy, OnlyAMissingPackageRefreshesTheIndex) { - EXPECT_TRUE(mcpp::pm::install_failure_policy("E_NOT_FOUND").refreshIndex); - EXPECT_TRUE(mcpp::pm::install_failure_policy("E_NOT_FOUND").retry); - EXPECT_FALSE(mcpp::pm::install_failure_policy("E_NETWORK").refreshIndex); - EXPECT_FALSE(mcpp::pm::install_failure_policy("E_NETWORK").retry); - EXPECT_TRUE(mcpp::pm::install_failure_policy("E_INVALID_INPUT").retry); +}}; + +UTest only_a_missing_package_refreshes_the_index{"PmInstallFailurePolicy/OnlyAMissingPackageRefreshesTheIndex", [] { + expect(bool(mcpp::pm::install_failure_policy("E_NOT_FOUND").refreshIndex)); + expect(bool(mcpp::pm::install_failure_policy("E_NOT_FOUND").retry)); + expect(!(mcpp::pm::install_failure_policy("E_NETWORK").refreshIndex)); + expect(!(mcpp::pm::install_failure_policy("E_NETWORK").retry)); + expect(bool(mcpp::pm::install_failure_policy("E_INVALID_INPUT").retry)); // A provider that said nothing (#238): the old conservative path. - EXPECT_TRUE(mcpp::pm::install_failure_policy("").refreshIndex); - EXPECT_TRUE(mcpp::pm::install_failure_policy("").retry); -} + expect(bool(mcpp::pm::install_failure_policy("").refreshIndex)); + expect(bool(mcpp::pm::install_failure_policy("").retry)); +}}; diff --git a/tests/unit/test_post_install.cpp b/tests/unit/test_post_install.cpp index 62e74a1fe..6e9f4ea3c 100644 --- a/tests/unit/test_post_install.cpp +++ b/tests/unit/test_post_install.cpp @@ -1,12 +1,14 @@ -#include #include import std; +import mcpplibs.mtest; import mcpp.toolchain.post_install; import mcpp.provider.xlings; import mcpp.config; import mcpp.toolchain.registry; +using namespace mcpplibs::mtest; + // detect_baked_loader parses gcc SPECS GRAMMAR, not plain text. The baked // loader path is embedded inside %-spec conditionals, e.g. // %{mmusl:/lib/ld-musl-x86_64.so.1;:/baked/dir/ld-linux-x86-64.so.2} @@ -30,36 +32,34 @@ const std::string kBakedSpecs = "%{mmusl:/lib/ld-musl-x86_64.so.1;:/opt/other-home/.xlings/data/xpkgs/xim-x-glibc/2.39/lib64/ld-linux-x86-64.so.2}}}}} " "%{static:-static}}\n"; -TEST(DetectBakedLoader, ExtractsExactPathWithoutSpecBraces) { +UTest extracts_exact_path_without_spec_braces{"DetectBakedLoader/ExtractsExactPathWithoutSpecBraces", [] { auto got = detect_baked_loader(kBakedSpecs); - EXPECT_EQ(got, - "/opt/other-home/.xlings/data/xpkgs/xim-x-glibc/2.39/lib64/ld-linux-x86-64.so.2"); + expect(eq(got, "/opt/other-home/.xlings/data/xpkgs/xim-x-glibc/2.39/lib64/ld-linux-x86-64.so.2")); // The regression: any brace in the result corrupts the specs on rewrite. - EXPECT_EQ(got.find('}'), std::string::npos); - EXPECT_EQ(got.find('{'), std::string::npos); -} + expect(eq(got.find('}'), std::string::npos)); + expect(eq(got.find('{'), std::string::npos)); +}}; -TEST(DetectBakedLoader, IgnoresPristineMultilibDefaults) { +UTest ignores_pristine_multilib_defaults{"DetectBakedLoader/IgnoresPristineMultilibDefaults", [] { // An unbaked spec (all-standard /lib*/ paths) must not be rewritten. const std::string pristine = "%{mmusl:/lib/ld-musl-x86_64.so.1;:/lib64/ld-linux-x86-64.so.2} " "%{m16|m32:-dynamic-linker /lib/ld-linux.so.2} " "%{mx32:-dynamic-linker /libx32/ld-linux-x32.so.2}"; - EXPECT_EQ(detect_baked_loader(pristine), ""); -} + expect(eq(detect_baked_loader(pristine), "")); +}}; -TEST(DetectBakedLoader, Aarch64LoaderNameDetected) { +UTest aarch64_loader_name_detected{"DetectBakedLoader/Aarch64LoaderNameDetected", [] { const std::string specs = "-dynamic-linker %{mmusl:/lib/ld-musl-aarch64.so.1;:" "/srv/build/.xlings/xpkgs/xim-x-glibc/2.39/lib/ld-linux-aarch64.so.1}"; - EXPECT_EQ(detect_baked_loader(specs), - "/srv/build/.xlings/xpkgs/xim-x-glibc/2.39/lib/ld-linux-aarch64.so.1"); -} + expect(eq(detect_baked_loader(specs), "/srv/build/.xlings/xpkgs/xim-x-glibc/2.39/lib/ld-linux-aarch64.so.1")); +}}; -TEST(DetectBakedLoader, EmptyWhenNoGnuLoaderPresent) { - EXPECT_EQ(detect_baked_loader("no loaders here"), ""); - EXPECT_EQ(detect_baked_loader("%{mmusl:/lib/ld-musl-x86_64.so.1}"), ""); -} +UTest empty_when_no_gnu_loader_present{"DetectBakedLoader/EmptyWhenNoGnuLoaderPresent", [] { + expect(eq(detect_baked_loader("no loaders here"), "")); + expect(eq(detect_baked_loader("%{mmusl:/lib/ld-musl-x86_64.so.1}"), "")); +}}; } // namespace @@ -112,45 +112,45 @@ struct GlibcRootFixture { } // namespace -TEST(GlibcPayload, TheExactVersionIsSelected) { +UTest the_exact_version_is_selected{"GlibcPayload/TheExactVersionIsSelected", [] { GlibcRootFixture fx{"mcpp_glibc_exact"}; auto want = make_glibc_payload(fx.root, "2.44.3"); make_glibc_payload(fx.root, "2.44.2"); auto got = mcpp::toolchain::select_glibc_payload_lib(fx.root, "glibc@2.44.3"); - ASSERT_TRUE(got.has_value()) << got.error(); - EXPECT_EQ(*got, want); -} + if (auto&& failed = expect(fatal(got.has_value())); !failed) failed << got.error(); + expect(eq(*got, want)); +}}; // THE CASE OF mcpp#660. Two revisions of the line are present and the declared // version is neither; the answer is "not installed", naming the coordinate // that provides it, and never one of the two. -TEST(GlibcPayload, ANeighbouringRevisionIsNotASubstitute) { +UTest a_neighbouring_revision_is_not_a_substitute{"GlibcPayload/ANeighbouringRevisionIsNotASubstitute", [] { GlibcRootFixture fx{"mcpp_glibc_neighbour"}; make_glibc_payload(fx.root, "2.44.2"); make_glibc_payload(fx.root, "2.44.3"); auto got = mcpp::toolchain::select_glibc_payload_lib(fx.root, "glibc@2.44"); - ASSERT_FALSE(got.has_value()); - EXPECT_NE(got.error().find("xim:glibc@2.44"), std::string::npos) << got.error(); - EXPECT_NE(got.error().find("not installed"), std::string::npos) << got.error(); + expect(fatal(!got.has_value())); + if (auto&& failed = expect(ne(got.error().find("xim:glibc@2.44"), std::string::npos)); !failed) failed << got.error(); + if (auto&& failed = expect(ne(got.error().find("not installed"), std::string::npos)); !failed) failed << got.error(); // One neighbour is not a substitute either: the old tolerance accepted it. GlibcRootFixture one{"mcpp_glibc_one_neighbour"}; make_glibc_payload(one.root, "2.44.2"); - EXPECT_FALSE(mcpp::toolchain::select_glibc_payload_lib(one.root, "glibc@2.44")); -} + expect(!mcpp::toolchain::select_glibc_payload_lib(one.root, "glibc@2.44")); +}}; -TEST(GlibcPayload, NothingInstalledIsStillRefused) { +UTest nothing_installed_is_still_refused{"GlibcPayload/NothingInstalledIsStillRefused", [] { GlibcRootFixture fx{"mcpp_glibc_empty"}; auto got = mcpp::toolchain::select_glibc_payload_lib(fx.root, "glibc@2.44"); - EXPECT_FALSE(got.has_value()); -} + expect(!got.has_value()); +}}; // A DIRECTORY WITHOUT A LOADER IS REPORTED AS INCOMPLETE, NOT AS ABSENT. The // two call for different repairs, so the messages must not coincide. -TEST(GlibcPayload, APayloadWithoutALoaderIsIncomplete) { +UTest a_payload_without_a_loader_is_incomplete{"GlibcPayload/APayloadWithoutALoaderIsIncomplete", [] { GlibcRootFixture fx{"mcpp_glibc_husk"}; std::filesystem::create_directories(fx.root / "2.44.3"); auto got = mcpp::toolchain::select_glibc_payload_lib(fx.root, "glibc@2.44.3"); - ASSERT_FALSE(got.has_value()); - EXPECT_NE(got.error().find("stale/incomplete"), std::string::npos) << got.error(); -} + expect(fatal(!got.has_value())); + if (auto&& failed = expect(ne(got.error().find("stale/incomplete"), std::string::npos)); !failed) failed << got.error(); +}}; diff --git a/tests/unit/test_predefines.cpp b/tests/unit/test_predefines.cpp index 32d7c7dc6..5d883ba26 100644 --- a/tests/unit/test_predefines.cpp +++ b/tests/unit/test_predefines.cpp @@ -9,7 +9,6 @@ // because a set compared by reading is a set compared by sampling --- this // project has already shipped two tables that were "the ones it was missing" // and were not. -#include #include #include @@ -17,9 +16,13 @@ #include #include +import mcpplibs.mtest; + import mcpp.toolchain.predefines; import mcpp.toolchain.triple; +using namespace mcpplibs::mtest; + namespace pd = mcpp::toolchain::predefines; namespace triple = mcpp::toolchain::triple; @@ -48,16 +51,16 @@ bool listed(std::string_view name, std::string_view os) { } // namespace -TEST(Predefines, EveryEmittedMacroIsInTheContract) { +UTest every_emitted_macro_is_in_the_contract{"Predefines/EveryEmittedMacroIsInTheContract", [] { for (auto const& os : {"linux", "windows", "macos", "ios", "android", "none"}) for (bool openkal : {false, true}) for (auto const& m : emitted(os, openkal)) - EXPECT_TRUE(listed(m, os)) + expect(bool(listed(m, os))) << m << " is emitted for os=" << os << " openkal=" << openkal << " and kContract does not list it"; -} +}}; -TEST(Predefines, EveryContractRowThisModuleEmitsIsReachable) { +UTest every_contract_row_this_module_emits_is_reachable{"Predefines/EveryContractRowThisModuleEmitsIsReachable", [] { // `Realisation` rows are emitted by `mcpp.toolchain.cenv`, deliberately, // and are listed here because the contract is about what a READER may // encounter rather than about which module wrote it. @@ -70,21 +73,19 @@ TEST(Predefines, EveryContractRowThisModuleEmitsIsReachable) { if (std::ranges::find(ms, pd::spelling_for(e, os)) != ms.end()) reached = true; } - EXPECT_TRUE(reached) + expect(bool(reached)) << e.spelling << " is in kContract and no configuration emits it"; } -} +}}; -TEST(Predefines, TheTargetMacroIsSpeltFromTheTripleField) { +UTest the_target_macro_is_spelt_from_the_triple_field{"Predefines/TheTargetMacroIsSpeltFromTheTripleField", [] { // The engine learns no operating-system name: a target added to the // triple parser gets its macro without an edit to this module. - EXPECT_EQ(pd::define_tokens("freebsd", false).front(), - "-D__MCPP_TARGET_FREEBSD__=1"); - EXPECT_EQ(pd::define_tokens("none", false).front(), - "-D__MCPP_TARGET_NONE__=1"); -} + expect(eq(pd::define_tokens("freebsd", false).front(), "-D__MCPP_TARGET_FREEBSD__=1")); + expect(eq(pd::define_tokens("none", false).front(), "-D__MCPP_TARGET_NONE__=1")); +}}; -TEST(Predefines, OneMacroPerTargetAndItIsAlwaysPresent) { +UTest one_macro_per_target_and_it_is_always_present{"Predefines/OneMacroPerTargetAndItIsAlwaysPresent", [] { // Conditional emission would make absence ambiguous: "not Windows" and // "Windows, but nothing suppressed its macros" would read the same. for (auto const& os : {"linux", "windows", "macos", "ios", "android", "none"}) { @@ -92,36 +93,36 @@ TEST(Predefines, OneMacroPerTargetAndItIsAlwaysPresent) { const auto n = std::ranges::count_if(ms, [](std::string const& m) { return m.starts_with("__MCPP_TARGET_"); }); - EXPECT_EQ(n, 1) << "os=" << os; + expect(eq(n, 1)) << "os=" << os; } -} +}}; -TEST(Predefines, OpenkalIsTiedToTheResolvedLayerAndNotToAnyTarget) { +UTest openkal_is_tied_to_the_resolved_layer_and_not_to_any_target{"Predefines/OpenkalIsTiedToTheResolvedLayerAndNotToAnyTarget", [] { for (auto const& os : {"linux", "windows", "macos"}) { auto without = emitted(os, false); auto with = emitted(os, true); - EXPECT_EQ(std::ranges::find(without, "__OPENKAL__"), without.end()); - EXPECT_NE(std::ranges::find(with, "__OPENKAL__"), with.end()); + expect(eq(std::ranges::find(without, "__OPENKAL__"), without.end())); + expect(ne(std::ranges::find(with, "__OPENKAL__"), with.end())); } -} +}}; -TEST(Predefines, AnEmptyTargetOsEmitsNoTargetMacro) { +UTest an_empty_target_os_emits_no_target_macro{"Predefines/AnEmptyTargetOsEmitsNoTargetMacro", [] { // A triple that failed to parse must not produce `__MCPP_TARGET___`. auto ms = emitted("", false); - EXPECT_TRUE(ms.empty()); -} + expect(ms.empty()); +}}; -TEST(Predefines, EveryContractRowStatesBothWhatIsAllowedAndWhatIsNot) { +UTest every_contract_row_states_both_what_is_allowed_and_what_is_not{"Predefines/EveryContractRowStatesBothWhatIsAllowedAndWhatIsNot", [] { // A rule with only a permission invites every use its author did not // think of; the `__OPENKAL__` entry has had a forbidden list since it was // written, and the rest follow it. for (auto const& e : pd::kContract) { - EXPECT_FALSE(e.allowed.empty()) << e.spelling; - EXPECT_FALSE(e.forbidden.empty()) << e.spelling; + expect(!e.allowed.empty()) << e.spelling; + expect(!e.forbidden.empty()) << e.spelling; } -} +}}; -TEST(Predefines, EveryOwnedNameIsSpeltInThisProjectsConvention) { +UTest every_owned_name_is_spelt_in_this_projects_convention{"Predefines/EveryOwnedNameIsSpeltInThisProjectsConvention", [] { // Upper case, `__`-wrapped. The convention splits by what a name IS: a // vendor or product name is upper (`__APPLE__`, `_WIN32`), a // kind-of-system name is lower (`__linux__`). Every owned row names a @@ -135,20 +136,20 @@ TEST(Predefines, EveryOwnedNameIsSpeltInThisProjectsConvention) { // and must never be pulled into this project's namespace. for (auto const& e : pd::kContract) { if (!e.owned) { - EXPECT_FALSE(e.spelling.starts_with("__MCPP_")) + expect(!e.spelling.starts_with("__MCPP_")) << e.spelling << " is supplied rather than owned, so it keeps " "the standard spelling"; continue; } - EXPECT_TRUE(e.spelling.starts_with("__") && e.spelling.ends_with("__")) + expect(e.spelling.starts_with("__") && e.spelling.ends_with("__")) << e.spelling << " is owned and is not `__`-wrapped"; for (char c : e.spelling) - EXPECT_FALSE(c >= 'a' && c <= 'z') + expect(!(c >= 'a' && c <= 'z')) << e.spelling << " is owned and carries a lower-case letter"; } -} +}}; -TEST(Predefines, EveryTargetInTheRegistryYieldsAValidIdentifier) { +UTest every_target_in_the_registry_yields_a_valid_identifier{"Predefines/EveryTargetInTheRegistryYieldsAValidIdentifier", [] { // The spelling is derived from the triple's own `os` field, which is why // a target added to the parser needs no edit to `predefines.cppm`. The // price of that is that an `os` carrying a character no identifier may @@ -162,20 +163,19 @@ TEST(Predefines, EveryTargetInTheRegistryYieldsAValidIdentifier) { std::size_t rows = 0; for (auto const& row : triple::known_targets()) { const auto t = triple::parse(row.canonical); - ASSERT_TRUE(t.has_value()) << row.canonical << " does not parse"; + expect(fatal(t.has_value())) << row.canonical << " does not parse"; const auto toks = pd::define_tokens(t->os, false); - ASSERT_EQ(toks.size(), 1u) << row.canonical << " (os=" << t->os << ")"; + if (auto&& failed = expect(fatal(eq(toks.size(), 1u))); !failed) failed << row.canonical << " (os=" << t->os << ")"; const std::string m = macro_of(toks.front()); - EXPECT_FALSE(m.empty()); - EXPECT_TRUE(std::isalpha(static_cast(m.front())) - || m.front() == '_') - << row.canonical << " -> " << m; + expect(!m.empty()); + if (auto&& failed = expect(std::isalpha(static_cast(m.front())) + || m.front() == '_'); !failed) failed << row.canonical << " -> " << m; for (char c : m) - EXPECT_TRUE(std::isalnum(static_cast(c)) || c == '_') + expect(std::isalnum(static_cast(c)) || c == '_') << row.canonical << " -> " << m << " is not a valid identifier"; ++rows; } // A registry that enumerated nothing would pass every assertion above. - EXPECT_GT(rows, 10u) << "the target registry produced " << rows << " rows"; -} + expect(gt(rows, 10u)) << "the target registry produced " << rows << " rows"; +}}; diff --git a/tests/unit/test_prepare_helpers.cpp b/tests/unit/test_prepare_helpers.cpp index a8d52ed2e..cfcaae315 100644 --- a/tests/unit/test_prepare_helpers.cpp +++ b/tests/unit/test_prepare_helpers.cpp @@ -5,61 +5,62 @@ // decomposition of prepare.cppm these were internal to one 16,000-line unit // and reached only through whole builds; they are exported for this file. -#include import std; +import mcpplibs.mtest; import mcpp.build.prepare; +using namespace mcpplibs::mtest; + using Words = std::vector; -TEST(FeatureRequest, TokensSplitOnCommasAndSpaces) { - EXPECT_EQ(mcpp::build::feature_request_tokens("a,b c,, d"), (Words{"a", "b", "c", "d"})); - EXPECT_TRUE(mcpp::build::feature_request_tokens("").empty()); - EXPECT_TRUE(mcpp::build::feature_request_tokens(" , ").empty()); -} +UTest tokens_split_on_commas_and_spaces{"FeatureRequest/TokensSplitOnCommasAndSpaces", [] { + expect(eq(mcpp::build::feature_request_tokens("a,b c,, d"), (Words{"a", "b", "c", "d"}))); + expect(mcpp::build::feature_request_tokens("").empty()); + expect(mcpp::build::feature_request_tokens(" , ").empty()); +}}; // #649 E8: a token with `/` opens a dependency's feature, so it is a forward // of the root and never one of the root's own features. A token with an // empty half stays whole, for the caller to name. -TEST(FeatureRequest, ASlashTokenIsAForwardAndNotARootFeature) { +UTest a_slash_token_is_a_forward_and_not_a_root_feature{"FeatureRequest/ASlashTokenIsAForwardAndNotARootFeature", [] { constexpr std::string_view request = "simd, dep/fast, gpu,/x,y/"; - EXPECT_EQ(mcpp::build::parse_feature_request(request), (Words{"simd", "gpu"})); - EXPECT_EQ(mcpp::build::feature_forward_request_tokens(request), - (Words{"dep/fast", "/x", "y/"})); -} + expect(eq(mcpp::build::parse_feature_request(request), (Words{"simd", "gpu"}))); + expect(eq(mcpp::build::feature_forward_request_tokens(request), (Words{"dep/fast", "/x", "y/"}))); +}}; -TEST(DefineName, IsTheTextBeforeTheFirstEquals) { - EXPECT_EQ(mcpp::build::define_name("NDEBUG"), "NDEBUG"); - EXPECT_EQ(mcpp::build::define_name("VERSION=1"), "VERSION"); - EXPECT_EQ(mcpp::build::define_name("EXPR=a=b"), "EXPR"); - EXPECT_EQ(mcpp::build::define_name("=x"), ""); -} +UTest is_the_text_before_the_first_equals{"DefineName/IsTheTextBeforeTheFirstEquals", [] { + expect(eq(mcpp::build::define_name("NDEBUG"), "NDEBUG")); + expect(eq(mcpp::build::define_name("VERSION=1"), "VERSION")); + expect(eq(mcpp::build::define_name("EXPR=a=b"), "EXPR")); + expect(eq(mcpp::build::define_name("=x"), "")); +}}; // The words an element reached the compiler as before flag_words: `$$` was a // literal dollar, and `${name}` or `$name` a ninja variable, empty on a // compile edge. A `defines` entry was read as `-D`. -TEST(PreviousReleaseWords, ReadNinjaEscapesAndVariablesAsTheOldEdgeDid) { - EXPECT_EQ(mcpp::build::previous_release_words("-DA=x$$y", false), (Words{"-DA=x$y"})); - EXPECT_EQ(mcpp::build::previous_release_words("-DA=${v}z", false), (Words{"-DA=z"})); - EXPECT_EQ(mcpp::build::previous_release_words("-DA=$v", false), (Words{"-DA="})); - EXPECT_EQ(mcpp::build::previous_release_words("B=1", true), (Words{"-DB=1"})); -} +UTest read_ninja_escapes_and_variables_as_the_old_edge_did{"PreviousReleaseWords/ReadNinjaEscapesAndVariablesAsTheOldEdgeDid", [] { + expect(eq(mcpp::build::previous_release_words("-DA=x$$y", false), (Words{"-DA=x$y"}))); + expect(eq(mcpp::build::previous_release_words("-DA=${v}z", false), (Words{"-DA=z"}))); + expect(eq(mcpp::build::previous_release_words("-DA=$v", false), (Words{"-DA="}))); + expect(eq(mcpp::build::previous_release_words("B=1", true), (Words{"-DB=1"}))); +}}; // A remote served by plain filesystem reads is local, so `--offline` keeps // building it; a scheme or scp-like syntax is remote. A Windows drive letter // has a colon but no `@`, so an existing `C:\repo` stays local. -TEST(GitRemote, LocalMeansAFileUrlOrAnExistingPath) { - EXPECT_TRUE(mcpp::build::is_local_git_remote("file:///srv/repo.git")); - EXPECT_FALSE(mcpp::build::is_local_git_remote("https://github.com/x/y.git")); - EXPECT_FALSE(mcpp::build::is_local_git_remote("ssh://git@host/x.git")); - EXPECT_FALSE(mcpp::build::is_local_git_remote("git@github.com:x/y.git")); +UTest local_means_a_file_url_or_an_existing_path{"GitRemote/LocalMeansAFileUrlOrAnExistingPath", [] { + expect(mcpp::build::is_local_git_remote("file:///srv/repo.git")); + expect(!mcpp::build::is_local_git_remote("https://github.com/x/y.git")); + expect(!mcpp::build::is_local_git_remote("ssh://git@host/x.git")); + expect(!mcpp::build::is_local_git_remote("git@github.com:x/y.git")); namespace fs = std::filesystem; const auto dir = fs::temp_directory_path() / std::format("mcpp-prepare-helpers-{:x}", std::random_device{}()); fs::create_directories(dir); - EXPECT_TRUE(mcpp::build::is_local_git_remote(dir.string())); - EXPECT_FALSE(mcpp::build::is_local_git_remote((dir / "missing").string())); + expect(mcpp::build::is_local_git_remote(dir.string())); + expect(!(mcpp::build::is_local_git_remote((dir / "missing").string()))); std::error_code ec; fs::remove_all(dir, ec); -} +}}; diff --git a/tests/unit/test_process_run_exec.cpp b/tests/unit/test_process_run_exec.cpp index 024b2df86..03cc99c10 100644 --- a/tests/unit/test_process_run_exec.cpp +++ b/tests/unit/test_process_run_exec.cpp @@ -1,12 +1,15 @@ -#include #include #include #include import std; +import mcpplibs.mtest; +import mcpplibs.os.stdio; import mcpp.run; import mcpp.invocation; +using namespace mcpplibs::mtest; + namespace process = mcpp::run; // These exercise the POSIX launch path with POSIX program paths (/bin/true, @@ -20,9 +23,9 @@ namespace process = mcpp::run; // change mcpp's own environment — that mutation is exactly what leaked into // /bin/sh and crashed it on newer-glibc hosts. The addition is the started // program's alone; this invocation's statement is what it was. -TEST(RunExec, OutputProducer) { +UTest output_producer{"RunExec/OutputProducer", [] { if (mcpp::invocation::get("MCPP_TEST_OUTPUT_PRODUCER") != "1") - GTEST_SKIP() << "subprocess producer"; + skip("subprocess producer"); std::fputs("mcpp-stderr-ready\n", stdout); std::fflush(stdout); const bool error = mcpp::invocation::get("MCPP_TEST_OUTPUT_STREAM") != "stdout"; @@ -39,13 +42,13 @@ TEST(RunExec, OutputProducer) { std::this_thread::sleep_until(next); } std::_Exit(7); -} +}}; -TEST(RunExec, QueuedOutputRenewsIdleAfterALineHandler) { +UTest queued_output_renews_idle_after_a_line_handler{"RunExec/QueuedOutputRenewsIdleAfterALineHandler", [] { std::vector ticks; const auto result = process::execute({mcpp::invocation::self_exe_path().string(), - "--gtest_filter=RunExec.OutputProducer"}, { - .env = {{"MCPP_TEST_OUTPUT_PRODUCER", "1"}, {"MCPP_TEST_OUTPUT_STREAM", "stdout"}, {"MCPP_TEST_OUTPUT_TICKS", "48"}}, + "RunExec/OutputProducer"}, { + .env = {{"MTEST_PROTOCOL", ""}, {"MCPP_TEST_OUTPUT_PRODUCER", "1"}, {"MCPP_TEST_OUTPUT_STREAM", "stdout"}, {"MCPP_TEST_OUTPUT_TICKS", "48"}}, .streams = process::Streams::merged, .total = std::chrono::seconds(3), .idle = std::chrono::milliseconds(500), .on_line = [&](std::string_view line) { @@ -55,30 +58,30 @@ TEST(RunExec, QueuedOutputRenewsIdleAfterALineHandler) { if (std::from_chars(line.data(), line.data() + line.size(), tick).ec == std::errc{}) ticks.push_back(tick); }}); - EXPECT_EQ(result.exit_code, 7); - EXPECT_FALSE(result.timed_out); - EXPECT_EQ(ticks.size(), 48u); -} + expect(eq(result.exit_code, 7)); + expect(!result.timed_out); + expect(eq(ticks.size(), 48u)); +}}; -TEST(RunExec, QueuedErrorOutputRenewsIdleAfterALineHandler) { +UTest queued_error_output_renews_idle_after_a_line_handler{"RunExec/QueuedErrorOutputRenewsIdleAfterALineHandler", [] { const auto result = process::execute({mcpp::invocation::self_exe_path().string(), - "--gtest_filter=RunExec.OutputProducer"}, { - .env = {{"MCPP_TEST_OUTPUT_PRODUCER", "1"}, {"MCPP_TEST_OUTPUT_TICKS", "48"}}, + "RunExec/OutputProducer"}, { + .env = {{"MTEST_PROTOCOL", ""}, {"MCPP_TEST_OUTPUT_PRODUCER", "1"}, {"MCPP_TEST_OUTPUT_TICKS", "48"}}, .streams = process::Streams::output_error_kept, .total = std::chrono::seconds(3), .idle = std::chrono::milliseconds(500), .on_line = [](std::string_view line) { if (line == "mcpp-stderr-ready") std::this_thread::sleep_for(std::chrono::milliseconds(750)); }}); - EXPECT_EQ(result.exit_code, 7) << result.error_output; - EXPECT_FALSE(result.timed_out); + expect(eq(result.exit_code, 7)) << result.error_output; + expect(!result.timed_out); std::istringstream input(result.error_output); int ticks = 0; for (long long tick; input >> tick;) ++ticks; - EXPECT_EQ(ticks, 48); -} + expect(eq(ticks, 48)); +}}; -TEST(RunExec, StreamCompletionAndChildWaitKeepTheirBounds) { +UTest stream_completion_and_child_wait_keep_their_bounds{"RunExec/StreamCompletionAndChildWaitKeepTheirBounds", [] { struct Case { const char* name; const char* command; @@ -102,7 +105,7 @@ TEST(RunExec, StreamCompletionAndChildWaitKeepTheirBounds) { {"stdout-EOF-total", "exec 1>&-; sleep 2", 50}, }; auto exercise = [](process::Streams streams, const Case& test) { - SCOPED_TRACE(std::format("stream={} case={}", static_cast(streams), test.name)); + /* trace: std::format("stream={} case={}", static_cast(streams), test.name) */; const auto started = std::chrono::steady_clock::now(); process::Options options; options.streams = streams; @@ -113,33 +116,34 @@ TEST(RunExec, StreamCompletionAndChildWaitKeepTheirBounds) { }; const bool producer = std::string_view(test.command) == "@stderr-producer"; if (producer) options.env.emplace_back("MCPP_TEST_OUTPUT_PRODUCER", "1"); + if (producer) options.env.emplace_back("MTEST_PROTOCOL", ""); const std::vector argv = producer ? std::vector{mcpp::invocation::self_exe_path().string(), - "--gtest_filter=RunExec.OutputProducer"} + "RunExec/OutputProducer"} : std::vector{"/bin/sh", "-c", test.command}; const auto result = process::execute(argv, options); if (test.exit >= 0) { - EXPECT_EQ(result.exit_code, test.exit); - EXPECT_FALSE(result.timed_out); + expect(eq(result.exit_code, test.exit)); + expect(!result.timed_out); } else { - EXPECT_NE(result.exit_code, 0); - EXPECT_TRUE(result.timed_out); + expect(ne(result.exit_code, 0)); + expect(bool(result.timed_out)); } const auto elapsed = std::chrono::steady_clock::now() - started; if (producer) { // One process emits across the idle window. Forking an external // sleep for every tick would add platform-dependent launch gaps. - SCOPED_TRACE(result.error_output); + /* trace: result.error_output */; std::istringstream input(result.error_output); std::vector ticks; for (long long tick; input >> tick;) ticks.push_back(tick); - ASSERT_EQ(ticks.size(), 24u); + expect(fatal(eq(ticks.size(), 24u))); for (std::size_t i = 1; i < ticks.size(); ++i) - EXPECT_LT(std::chrono::nanoseconds(ticks[i] - ticks[i - 1]), options.idle); - EXPECT_GT(elapsed, options.idle); - EXPECT_LT(elapsed, std::chrono::seconds(3)); + expect(lt(std::chrono::nanoseconds(ticks[i] - ticks[i - 1]), options.idle)); + expect(gt(elapsed, options.idle)); + expect(lt(elapsed, std::chrono::seconds(3))); } else { - EXPECT_LT(elapsed, std::chrono::seconds(1)); + expect(lt(elapsed, std::chrono::seconds(1))); } }; for (const auto streams : {process::Streams::inherit, process::Streams::discard, @@ -151,21 +155,21 @@ TEST(RunExec, StreamCompletionAndChildWaitKeepTheirBounds) { } exercise(process::Streams::output_error_kept, {"stderr-activity", "@stderr-producer", 3000, 500, false, 7}); -} +}}; -TEST(RunExec, StopPredicateAppliesAfterCapturedStreamsClose) { +UTest stop_predicate_applies_after_captured_streams_close{"RunExec/StopPredicateAppliesAfterCapturedStreamsClose", [] { const auto started = std::chrono::steady_clock::now(); const auto result = process::execute({"/bin/sh", "-c", "exec 1>&- 2>&-; sleep 2"}, { .streams = process::Streams::merged, .should_stop = [&] { return std::chrono::steady_clock::now() - started > std::chrono::milliseconds(50); }}); - EXPECT_TRUE(result.timed_out); - EXPECT_NE(result.exit_code, 0); - EXPECT_LT(std::chrono::steady_clock::now() - started, std::chrono::seconds(1)); -} + expect(bool(result.timed_out)); + expect(ne(result.exit_code, 0)); + expect(lt(std::chrono::steady_clock::now() - started, std::chrono::seconds(1))); +}}; -TEST(RunExec, StopPredicateAppliesWithoutCapturedStreams) { +UTest stop_predicate_applies_without_captured_streams{"RunExec/StopPredicateAppliesWithoutCapturedStreams", [] { for (const auto streams : {process::Streams::inherit, process::Streams::discard}) { const auto started = std::chrono::steady_clock::now(); int checks = 0; @@ -175,60 +179,60 @@ TEST(RunExec, StopPredicateAppliesWithoutCapturedStreams) { ++checks; return std::chrono::steady_clock::now() - started > std::chrono::milliseconds(50); }}); - EXPECT_GT(checks, 0); - EXPECT_TRUE(result.timed_out); - EXPECT_NE(result.exit_code, 0); - EXPECT_LT(std::chrono::steady_clock::now() - started, std::chrono::seconds(1)); + expect(gt(checks, 0)); + expect(bool(result.timed_out)); + expect(ne(result.exit_code, 0)); + expect(lt(std::chrono::steady_clock::now() - started, std::chrono::seconds(1))); } -} +}}; -TEST(RunExec, DoesNotMutateParentEnvironment) { +UTest does_not_mutate_parent_environment{"RunExec/DoesNotMutateParentEnvironment", [] { mcpp::invocation::ScopedEnv leak("MCPP_TEST_LEAK", "sentinel"); // Use /bin/sh (present on Linux AND macOS) — /bin/true lives at // /usr/bin/true on macOS, so it is not a portable launch target. int rc = process::run_exec({"/bin/sh", "-c", "[ \"$MCPP_TEST_LEAK\" = injected ]"}, {{"MCPP_TEST_LEAK", "injected"}}); - EXPECT_EQ(rc, 0); - EXPECT_EQ(mcpp::invocation::get("MCPP_TEST_LEAK"), "sentinel"); // unchanged → no leak -} + expect(eq(rc, 0)); + expect(eq(mcpp::invocation::get("MCPP_TEST_LEAK"), "sentinel")); // unchanged → no leak +}}; -TEST(RunExec, ChildSeesInjectedEnv) { +UTest child_sees_injected_env{"RunExec/ChildSeesInjectedEnv", [] { // `sh -c '[ "$X" = injected ]'` exits 0 only if the child received X. int rc = process::run_exec( {"/bin/sh", "-c", "[ \"$MCPP_TEST_INJECT\" = injected ]"}, {{"MCPP_TEST_INJECT", "injected"}}); - EXPECT_EQ(rc, 0); -} + expect(eq(rc, 0)); +}}; -TEST(RunExec, PropagatesChildExitCode) { - EXPECT_EQ(process::run_exec({"/bin/sh", "-c", "exit 7"}), 7); -} +UTest propagates_child_exit_code{"RunExec/PropagatesChildExitCode", [] { + expect(eq(process::run_exec({"/bin/sh", "-c", "exit 7"}), 7)); +}}; -TEST(RunExec, ReturnsErrorWhenProgramMissing) { - EXPECT_NE(process::run_exec({"/no/such/program/mcpp-xyz"}), 0); -} +UTest returns_error_when_program_missing{"RunExec/ReturnsErrorWhenProgramMissing", [] { + expect(ne(process::run_exec({"/no/such/program/mcpp-xyz"}), 0)); +}}; -TEST(CaptureExec, MissingProgramReportsSpawnFailure) { +UTest missing_program_reports_spawn_failure{"CaptureExec/MissingProgramReportsSpawnFailure", [] { constexpr std::string_view missing = "/no/such/program/mcpp-capture-xyz"; auto r = process::capture_exec({std::string(missing)}); - EXPECT_EQ(r.exit_code, 127); - EXPECT_NE(r.output.find(missing), std::string::npos) << r.output; - EXPECT_NE(r.output.find("error 2"), std::string::npos) << r.output; -} + expect(eq(r.exit_code, 127)); + expect(ne(r.output.find(missing), std::string::npos)) << r.output; + expect(ne(r.output.find("error 2"), std::string::npos)) << r.output; +}}; -TEST(CaptureExec, CapturesStdoutWithoutShell) { +UTest captures_stdout_without_shell{"CaptureExec/CapturesStdoutWithoutShell", [] { auto r = process::capture_exec({"/bin/echo", "hello world"}); - EXPECT_EQ(r.exit_code, 0); - EXPECT_EQ(r.output, "hello world\n"); -} + expect(eq(r.exit_code, 0)); + expect(eq(r.output, "hello world\n")); +}}; -TEST(CaptureExec, CapturesStderrCombined) { +UTest captures_stderr_combined{"CaptureExec/CapturesStderrCombined", [] { // stderr must be merged into output (replaces the old `2>&1`), otherwise // ninja's error text would be lost from the fast-path stale detector. auto r = process::capture_exec({"/bin/sh", "-c", "echo oops 1>&2"}); - EXPECT_EQ(r.exit_code, 0); - EXPECT_EQ(r.output, "oops\n"); -} + expect(eq(r.exit_code, 0)); + expect(eq(r.output, "oops\n")); +}}; // #248 regression: extraEnv AND cwd BOTH non-empty — the previously untested // combination (every prior call site passed at most one of the two; the only @@ -237,29 +241,29 @@ TEST(CaptureExec, CapturesStderrCombined) { // first command (`cd`) across `&&` — so the real program received NO extra env // and build.mcpp lost the whole MCPP_* contract. Linux and macOS now share the // posix_spawn path, so this locks the contract on both. -TEST(CaptureExec, EnvAndCwdCombinedBothReachChild) { +UTest env_and_cwd_combined_both_reach_child{"CaptureExec/EnvAndCwdCombinedBothReachChild", [] { auto dir = std::filesystem::temp_directory_path() / "mcpp_248_env_cwd"; std::filesystem::create_directories(dir); auto r = process::capture_exec( {"/bin/sh", "-c", "printf '%s|%s' \"$MCPP_TEST_CONTRACT\" \"$PWD\""}, {{"MCPP_TEST_CONTRACT", "hello"}}, dir.string()); - EXPECT_EQ(r.exit_code, 0); + expect(eq(r.exit_code, 0)); // Env value must reach the child (the old prefix bound it to `cd` only). - EXPECT_NE(r.output.find("hello|"), std::string::npos) << r.output; + expect(ne(r.output.find("hello|"), std::string::npos)) << r.output; // Child must actually run in `cwd`. Compare the leaf name, not the full // path: macOS resolves /var → /private/var in $PWD. - EXPECT_NE(r.output.find("mcpp_248_env_cwd"), std::string::npos) << r.output; -} + expect(ne(r.output.find("mcpp_248_env_cwd"), std::string::npos)) << r.output; +}}; // Companion check for run_exec (no cwd parameter there, but extraEnv must // still land in the child via the same merged_environ posix_spawn path). -TEST(RunExec, InjectedEnvSurvivesShellChildChain) { +UTest injected_env_survives_shell_child_chain{"RunExec/InjectedEnvSurvivesShellChildChain", [] { int rc = process::run_exec( {"/bin/sh", "-c", "[ \"$MCPP_TEST_CONTRACT\" = hello ]"}, {{"MCPP_TEST_CONTRACT", "hello"}}); - EXPECT_EQ(rc, 0); -} + expect(eq(rc, 0)); +}}; // ── spawn failures are typed, reported once, and never retried (#544) ────── // @@ -271,24 +275,24 @@ TEST(RunExec, InjectedEnvSurvivesShellChildChain) { // aarch64-linux-musl` on a host without an emulator printed "Running …", a // blank line, and exited 1. -TEST(RunExec, MissingProgramReportsOnStderrWhenCallerDoesNotAsk) { - testing::internal::CaptureStderr(); +UTest missing_program_reports_on_stderr_when_caller_does_not_ask{"RunExec/MissingProgramReportsOnStderrWhenCallerDoesNotAsk", [] { + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); int rc = process::run_exec({"/no/such/program/mcpp-run-xyz"}); - auto err = testing::internal::GetCapturedStderr(); - EXPECT_EQ(rc, 127); - EXPECT_NE(err.find("/no/such/program/mcpp-run-xyz"), std::string::npos) << err; - EXPECT_NE(err.find("error 2"), std::string::npos) << err; -} + auto err = stderrCapture.text(); + expect(eq(rc, 127)); + expect(ne(err.find("/no/such/program/mcpp-run-xyz"), std::string::npos)) << err; + expect(ne(err.find("error 2"), std::string::npos)) << err; +}}; -TEST(RunExec, MissingProgramTypesErrnoWhenCallerAsks) { +UTest missing_program_types_errno_when_caller_asks{"RunExec/MissingProgramTypesErrnoWhenCallerAsks", [] { int spawnErr = 0; - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); int rc = process::run_exec({"/no/such/program/mcpp-run-xyz"}, {}, &spawnErr); - auto err = testing::internal::GetCapturedStderr(); - EXPECT_EQ(rc, 127); - EXPECT_EQ(spawnErr, ENOENT); - EXPECT_TRUE(err.empty()) << err; // the caller owns the report -} + auto err = stderrCapture.text(); + expect(eq(rc, 127)); + expect(eq(spawnErr, ENOENT)); + expect(err.empty()) << err; // the caller owns the report +}}; #if defined(__linux__) // Linux only, and that is a MEASUREMENT (CI, macOS 14 ARM64, 2026-09-02): on @@ -299,7 +303,7 @@ TEST(RunExec, MissingProgramTypesErrnoWhenCallerAsks) { // synthesises. `mcpp run`'s "this host cannot execute" typing therefore fires // on Linux for any unloadable file and on macOS only for a real foreign // binary; see the design's open question 1. -TEST(RunExec, UnloadableArtifactIsENOEXEC) { +UTest unloadable_artifact_is_enoexec{"RunExec/UnloadableArtifactIsENOEXEC", [] { // An executable file with no loader magic: the kernel refuses it with // ENOEXEC. glibc's posix_spawnp does not retry through /bin/sh the way // execvp does, so the errno reaches the caller untouched. This is the @@ -311,120 +315,120 @@ TEST(RunExec, UnloadableArtifactIsENOEXEC) { std::filesystem::permissions(f, std::filesystem::perms::owner_all); int spawnErr = 0; int rc = process::run_exec({f.string()}, {}, &spawnErr); - EXPECT_EQ(rc, 127); - EXPECT_EQ(spawnErr, ENOEXEC); -} + expect(eq(rc, 127)); + expect(eq(spawnErr, ENOEXEC)); +}}; #endif -TEST(RunExec, SpawnedChildLeavesSpawnErrorZero) { +UTest spawned_child_leaves_spawn_error_zero{"RunExec/SpawnedChildLeavesSpawnErrorZero", [] { int spawnErr = -1; - EXPECT_EQ(process::run_exec({"/bin/sh", "-c", "exit 7"}, {}, &spawnErr), 7); - EXPECT_EQ(spawnErr, 0); -} + expect(eq(process::run_exec({"/bin/sh", "-c", "exit 7"}, {}, &spawnErr), 7)); + expect(eq(spawnErr, 0)); +}}; -TEST(CaptureExec, MissingProgramTypesErrnoWhenCallerAsks) { +UTest capture_exec_missing_program_types_errno_when_caller_asks{"CaptureExec/MissingProgramTypesErrnoWhenCallerAsks", [] { int spawnErr = 0; auto r = process::capture_exec({"/no/such/program/mcpp-capture-typed"}, {}, {}, &spawnErr); - EXPECT_EQ(r.exit_code, 127); - EXPECT_EQ(spawnErr, ENOENT); - EXPECT_TRUE(r.output.empty()) << r.output; -} + expect(eq(r.exit_code, 127)); + expect(eq(spawnErr, ENOENT)); + expect(r.output.empty()) << r.output; +}}; -TEST(RunExecDeadline, SpawnFailureIsTypedAndNotRetried) { +UTest spawn_failure_is_typed_and_not_retried{"RunExecDeadline/SpawnFailureIsTypedAndNotRetried", [] { int spawnErr = 0; bool timedOut = true; - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); int rc = process::run_exec_deadline({"/no/such/program/mcpp-dl-xyz"}, {}, std::chrono::milliseconds(5000), &timedOut, &spawnErr); - auto err = testing::internal::GetCapturedStderr(); - EXPECT_EQ(rc, 127); - EXPECT_EQ(spawnErr, ENOENT); - EXPECT_FALSE(timedOut); - EXPECT_TRUE(err.empty()) << err; -} - -TEST(RunExecDeadline, SpawnFailureIsReportedWhenCallerDoesNotAsk) { + auto err = stderrCapture.text(); + expect(eq(rc, 127)); + expect(eq(spawnErr, ENOENT)); + expect(!timedOut); + expect(err.empty()) << err; +}}; + +UTest spawn_failure_is_reported_when_caller_does_not_ask{"RunExecDeadline/SpawnFailureIsReportedWhenCallerDoesNotAsk", [] { bool timedOut = true; - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); int rc = process::run_exec_deadline({"/no/such/program/mcpp-dl-untyped"}, {}, std::chrono::milliseconds(5000), &timedOut); - auto err = testing::internal::GetCapturedStderr(); - EXPECT_EQ(rc, 127); - EXPECT_FALSE(timedOut); - EXPECT_NE(err.find("mcpp-dl-untyped"), std::string::npos) << err; - EXPECT_NE(err.find("error 2"), std::string::npos) << err; -} - -TEST(CaptureExecDeadline, SpawnFailureIsTypedInOutcome) { + auto err = stderrCapture.text(); + expect(eq(rc, 127)); + expect(!timedOut); + expect(ne(err.find("mcpp-dl-untyped"), std::string::npos)) << err; + expect(ne(err.find("error 2"), std::string::npos)) << err; +}}; + +UTest spawn_failure_is_typed_in_outcome{"CaptureExecDeadline/SpawnFailureIsTypedInOutcome", [] { int spawnErr = 0; bool timedOut = true; auto r = process::capture_exec_deadline({"/no/such/program/mcpp-cdl-xyz"}, {}, std::chrono::milliseconds(5000), &timedOut, {}, &spawnErr); - EXPECT_EQ(r.exit_code, 127); - EXPECT_EQ(spawnErr, ENOENT); - EXPECT_FALSE(timedOut); - EXPECT_TRUE(r.output.empty()) << r.output; -} + expect(eq(r.exit_code, 127)); + expect(eq(spawnErr, ENOENT)); + expect(!timedOut); + expect(r.output.empty()) << r.output; +}}; -TEST(CaptureExecDeadline, SpawnFailureIsInOutputWhenCallerDoesNotAsk) { +UTest spawn_failure_is_in_output_when_caller_does_not_ask{"CaptureExecDeadline/SpawnFailureIsInOutputWhenCallerDoesNotAsk", [] { bool timedOut = true; auto r = process::capture_exec_deadline({"/no/such/program/mcpp-cdl-untyped"}, {}, std::chrono::milliseconds(5000), &timedOut); - EXPECT_EQ(r.exit_code, 127); - EXPECT_NE(r.output.find("mcpp-cdl-untyped"), std::string::npos) << r.output; - EXPECT_NE(r.output.find("error 2"), std::string::npos) << r.output; -} + expect(eq(r.exit_code, 127)); + expect(ne(r.output.find("mcpp-cdl-untyped"), std::string::npos)) << r.output; + expect(ne(r.output.find("error 2"), std::string::npos)) << r.output; +}}; // ── capture_stdout: the argument-vector form of ` 2>/dev/null` ── -TEST(CaptureStdout, CapturesStandardOutputOnly) { +UTest captures_standard_output_only{"CaptureStdout/CapturesStandardOutputOnly", [] { auto r = process::capture_stdout( {"/bin/sh", "-c", "echo out; echo 9.9.9-on-stderr 1>&2"}); - EXPECT_EQ(r.exit_code, 0); - EXPECT_EQ(r.output, "out\n"); -} + expect(eq(r.exit_code, 0)); + expect(eq(r.output, "out\n")); +}}; -TEST(CaptureStdout, PropagatesExitCode) { +UTest propagates_exit_code{"CaptureStdout/PropagatesExitCode", [] { auto r = process::capture_stdout({"/bin/sh", "-c", "echo partial; exit 3"}); - EXPECT_EQ(r.exit_code, 3); - EXPECT_EQ(r.output, "partial\n"); -} + expect(eq(r.exit_code, 3)); + expect(eq(r.output, "partial\n")); +}}; // A probe parses what it captured, so a launcher message must never be read // as the program's answer: a missing program leaves the output empty. -TEST(CaptureStdout, MissingProgramLeavesOutputEmpty) { +UTest missing_program_leaves_output_empty{"CaptureStdout/MissingProgramLeavesOutputEmpty", [] { int spawnErr = 0; auto r = process::capture_stdout({"/no/such/program/mcpp-probe-2026.1.2.3"}, {}, &spawnErr); - EXPECT_EQ(r.exit_code, 127); - EXPECT_EQ(spawnErr, ENOENT); - EXPECT_TRUE(r.output.empty()) << r.output; -} + expect(eq(r.exit_code, 127)); + expect(eq(spawnErr, ENOENT)); + expect(r.output.empty()) << r.output; +}}; -TEST(CaptureStdout, StandardInputIsEmpty) { +UTest standard_input_is_empty{"CaptureStdout/StandardInputIsEmpty", [] { // `cat` returns at once with nothing when stdin is /dev/null; with an // inherited terminal or pipe it would block or echo the parent's input. auto r = process::capture_stdout({"/bin/sh", "-c", "cat; echo done"}); - EXPECT_EQ(r.exit_code, 0); - EXPECT_EQ(r.output, "done\n"); -} + expect(eq(r.exit_code, 0)); + expect(eq(r.output, "done\n")); +}}; -TEST(CaptureStdout, ExtraEnvironmentReachesTheChild) { +UTest extra_environment_reaches_the_child{"CaptureStdout/ExtraEnvironmentReachesTheChild", [] { auto r = process::capture_stdout({"/bin/sh", "-c", "printf %s \"$MCPP_PROBE_ENV\""}, {{"MCPP_PROBE_ENV", "reached"}}); - EXPECT_EQ(r.exit_code, 0); - EXPECT_EQ(r.output, "reached"); -} + expect(eq(r.exit_code, 0)); + expect(eq(r.output, "reached")); +}}; #else // _WIN32 -TEST(RunExec, WindowsCoveredByIntegration) { +UTest windows_covered_by_integration{"RunExec/WindowsCoveredByIntegration", [] { // The Windows path uses _spawnvpe (no /bin/* programs to point at here); // it is exercised by the integration build that launches ninja. - SUCCEED(); -} + /* passes */; +}}; #endif diff --git a/tests/unit/test_process_seal_stdin.cpp b/tests/unit/test_process_seal_stdin.cpp index 62097b77a..463a13252 100644 --- a/tests/unit/test_process_seal_stdin.cpp +++ b/tests/unit/test_process_seal_stdin.cpp @@ -12,14 +12,16 @@ // wait for as long as the pipe is open, and the copy with it; the outer test // bounds that wait. -#include import std; +import mcpplibs.mtest; import mcpp.run; import mcpp.invocation; import mcpplibs.os.platform; import mcpplibs.os.process; +using namespace mcpplibs::mtest; + namespace { namespace os = mcpplibs::os; @@ -40,9 +42,9 @@ std::optional child_mode() { return mcpp::invocation::get("MCPP_TES // The copy started by the test below: starts the reader through the launcher // it is named, with this process's input the pipe nobody writes. -TEST(ProcessSealStdin, Child) { +UTest process_seal_stdin_child{"ProcessSealStdin/Child", [] { const auto mode = child_mode(); - if (!mode) GTEST_SKIP() << "run by AProgramReadsAnEmptyInput"; + if (!mode) skip("run by AProgramReadsAnEmptyInput"); if (*mode == "execute") { (void)mcpp::run::execute(reader(), { .streams = mcpp::run::Streams::discard }); } else if (*mode == "stream") { @@ -53,26 +55,26 @@ TEST(ProcessSealStdin, Child) { } else if (*mode == "shell") { (void)mcpp::run::capture_shell(os::is_windows ? "more >nul 2>&1" : "cat >/dev/null"); } -} +}}; -TEST(ProcessSealStdin, AProgramReadsAnEmptyInput) { - if (child_mode()) GTEST_SKIP(); +UTest a_program_reads_an_empty_input{"ProcessSealStdin/AProgramReadsAnEmptyInput", [] { + if (child_mode()) skip(""); for (std::string mode : { "execute", "stream", "probe", "shell" }) { os::process::spawn_options o; o.program = mcpp::invocation::self_exe_path(); - o.args = { "--gtest_filter=ProcessSealStdin.Child" }; - o.env = { { "MCPP_TEST_STDIN_CHILD", mode } }; + o.args = { "ProcessSealStdin/Child" }; + o.env = { { "MCPP_TEST_STDIN_CHILD", mode }, { "MTEST_PROTOCOL", "" } }; o.in = os::process::stdio::pipe; // held open, never written o.out = o.err = os::process::stdio::null; o.own_unit = true; auto p = os::process::process::spawn(o); - ASSERT_TRUE(p) << mode; + expect(fatal(bool(p))) << mode; auto w = p->wait_for(kMaxSealed); if (!w || !w->has_value()) { p->end_unit(); p->kill(); - FAIL() << mode << ": the started program waited on mcpp's input"; + fail([&] { std::ostringstream text; text << mode << ": the started program waited on mcpp's input"; return text.str(); }()); } - EXPECT_TRUE((*w)->success()) << mode; + expect(bool((*w)->success())) << mode; } -} +}}; diff --git a/tests/unit/test_progress_render.cpp b/tests/unit/test_progress_render.cpp index aaf6d09b5..7048d944a 100644 --- a/tests/unit/test_progress_render.cpp +++ b/tests/unit/test_progress_render.cpp @@ -1,37 +1,40 @@ -#include import std; +import mcpplibs.mtest; +import mcpplibs.os.stdio; import mcpp.ui; import mcpp.fetcher.progress; import mcpp.run; import mcpplibs.os.platform; +using namespace mcpplibs::mtest; + // W11: one renderer for every acquisition, with a terminal mode (redrawn in // place) and a plain mode; either prints one line per item (#734). The renderer // narrates, and narration is on standard error (output streams plan // 2026-10-01, R3), which is what these tests capture. -TEST(GitProgress, ParsesThePhaseAndThePercentage) { +UTest parses_the_phase_and_the_percentage{"GitProgress/ParsesThePhaseAndThePercentage", [] { auto r = mcpp::fetcher::parse_git_progress( "Receiving objects: 45% (450/1000), 1.20 MiB | 800.00 KiB/s"); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(r->phase, "Receiving objects"); - EXPECT_EQ(r->percent, 45u); + expect(fatal(r.has_value())); + expect(eq(r->phase, "Receiving objects")); + expect(eq(r->percent, 45u)); auto remote = mcpp::fetcher::parse_git_progress("remote: Counting objects: 100% (5/5), done."); - ASSERT_TRUE(remote.has_value()); - EXPECT_EQ(remote->phase, "Counting objects"); - EXPECT_EQ(remote->percent, 100u); + expect(fatal(remote.has_value())); + expect(eq(remote->phase, "Counting objects")); + expect(eq(remote->percent, 100u)); - EXPECT_FALSE(mcpp::fetcher::parse_git_progress("Cloning into 'x'...").has_value()); - EXPECT_FALSE(mcpp::fetcher::parse_git_progress("fatal: repository not found").has_value()); - EXPECT_FALSE(mcpp::fetcher::parse_git_progress("").has_value()); -} + expect(!(mcpp::fetcher::parse_git_progress("Cloning into 'x'...").has_value())); + expect(!(mcpp::fetcher::parse_git_progress("fatal: repository not found").has_value())); + expect(!(mcpp::fetcher::parse_git_progress("").has_value())); +}}; -TEST(ProgressBarPlain, OneCompletionLineWithoutRepaints) { +UTest one_completion_line_without_repaints{"ProgressBarPlain/OneCompletionLineWithoutRepaints", [] { mcpp::ui::disable_color(); mcpp::ui::set_live_progress(false); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); { mcpp::ui::ProgressBar bar("Fetching", "example"); bar.update_bytes(10, 100, 0.1); @@ -39,65 +42,65 @@ TEST(ProgressBarPlain, OneCompletionLineWithoutRepaints) { bar.update_bytes(100, 100, 1.0); bar.finish(); } - auto out = testing::internal::GetCapturedStderr(); - EXPECT_EQ(out.find('\r'), std::string::npos) << out; - EXPECT_EQ(out.find('\x1b'), std::string::npos) << out; + auto out = stderrCapture.text(); + expect(eq(out.find('\r'), std::string::npos)) << out; + expect(eq(out.find('\x1b'), std::string::npos)) << out; // #734: one line per item, stating the size and the time. - EXPECT_EQ(std::ranges::count(out, '\n'), 1) << out; - EXPECT_NE(out.find("Fetching example done, 100 B in"), std::string::npos) << out; -} + expect(eq(std::ranges::count(out, '\n'), 1)) << out; + expect(ne(out.find("Fetching example done, 100 B in"), std::string::npos)) << out; +}}; -TEST(ProgressBarPlain, AFailedItemIsNotReportedDone) { +UTest a_failed_item_is_not_reported_done{"ProgressBarPlain/AFailedItemIsNotReportedDone", [] { mcpp::ui::disable_color(); mcpp::ui::set_live_progress(false); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); { mcpp::ui::ProgressBar bar("Installing", "xim:example"); bar.update_indeterminate(0, 0.2); bar.finish_failed("xim:example"); } - auto out = testing::internal::GetCapturedStderr(); - EXPECT_NE(out.find("did not complete"), std::string::npos) << out; - EXPECT_EQ(out.find(" done"), std::string::npos) << out; -} + auto out = stderrCapture.text(); + expect(ne(out.find("did not complete"), std::string::npos)) << out; + expect(eq(out.find(" done"), std::string::npos)) << out; +}}; -TEST(DownloadProgressPlain, AnInterruptedDownloadIsNotReportedDone) { +UTest an_interrupted_download_is_not_reported_done{"DownloadProgressPlain/AnInterruptedDownloadIsNotReportedDone", [] { mcpp::ui::disable_color(); mcpp::ui::set_live_progress(false); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); { mcpp::ui::DownloadProgress dl; const mcpp::ui::DownloadFile f{"xim-index.tar.gz", 10, 100, true, false}; dl.update(std::span{&f, 1}, 0.2); dl.finish_failed(); } - auto out = testing::internal::GetCapturedStderr(); - EXPECT_NE(out.find("xim-index.tar.gz did not complete"), std::string::npos) << out; - EXPECT_EQ(out.find(" done"), std::string::npos) << out; -} + auto out = stderrCapture.text(); + expect(ne(out.find("xim-index.tar.gz did not complete"), std::string::npos)) << out; + expect(eq(out.find(" done"), std::string::npos)) << out; +}}; -TEST(ProgressBarLive, RedrawsInPlace) { +UTest redraws_in_place{"ProgressBarLive/RedrawsInPlace", [] { mcpp::ui::disable_color(); mcpp::ui::set_live_progress(true); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); { mcpp::ui::ProgressBar bar("Fetching", "example"); bar.update(100); bar.finish(); } - auto out = testing::internal::GetCapturedStderr(); + auto out = stderrCapture.text(); mcpp::ui::set_live_progress(false); - EXPECT_NE(out.find('\r'), std::string::npos) << out; -} + expect(ne(out.find('\r'), std::string::npos)) << out; +}}; -TEST(StreamingBounded, ACarriageReturnEndsALineOnlyWhenAsked) { - if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "printf is a POSIX program"; +UTest a_carriage_return_ends_a_line_only_when_asked{"StreamingBounded/ACarriageReturnEndsALineOnlyWhenAsked", [] { + if constexpr (mcpplibs::os::is_windows) skip("printf is a POSIX program"); const std::vector argv{ "printf", "a\\rb\\r\\nc\\n" }; std::vector plain, split; (void)mcpp::run::execute(argv, { .idle = std::chrono::seconds{30}, .on_line = [&](std::string_view l) { plain.emplace_back(l); } }); (void)mcpp::run::execute(argv, { .idle = std::chrono::seconds{30}, .on_line = [&](std::string_view l) { split.emplace_back(l); }, .split_on_cr = true }); - EXPECT_EQ(plain, (std::vector{"a\rb", "c"})); - EXPECT_EQ(split, (std::vector{"a", "b", "c"})); -} + expect(eq(plain, (std::vector{"a\rb", "c"}))); + expect(eq(split, (std::vector{"a", "b", "c"}))); +}}; diff --git a/tests/unit/test_provisions.cpp b/tests/unit/test_provisions.cpp index 9955f4a39..31e982be7 100644 --- a/tests/unit/test_provisions.cpp +++ b/tests/unit/test_provisions.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.provisions; +using namespace mcpplibs::mtest; + // A build-time provision — a host tool, a host build rule, a dependency's // directory — is something a dependency hands to its consumer's build PROGRAM. // Before #359 each kind invented its own reach, and none could be re-exported, @@ -46,61 +48,61 @@ bool sees_dir(const prov::Propagation& p, std::size_t consumer, // ── Table integrity ──────────────────────────────────────────────────────── -TEST(Provisions, EveryKindAnswersBothQuestions) { +UTest every_kind_answers_both_questions{"Provisions/EveryKindAnswersBothQuestions", [] { // The table exists so a new provision kind cannot be added without someone // stating how far it travels. A row that answered neither would compile // fine and silently pick a default, which is the exact failure #359 fixed. std::set seen; for (auto const& d : prov::kTable) { - EXPECT_FALSE(d.name.empty()); - EXPECT_TRUE(d.needsReexport) << d.name; + expect(!d.name.empty()); + expect(bool(d.needsReexport)) << d.name; seen.insert(d.kind); } - EXPECT_EQ(seen.size(), 3u); - EXPECT_TRUE(prov::def_of(prov::Kind::Tool).bareAddressable); - EXPECT_TRUE(prov::def_of(prov::Kind::DepDir).bareAddressable); + expect(eq(seen.size(), 3u)); + expect(bool(prov::def_of(prov::Kind::Tool).bareAddressable)); + expect(bool(prov::def_of(prov::Kind::DepDir).bareAddressable)); // A host module is addressed by `import ;` — the compiler resolves // it, so there is no bare-name channel for mcpp to bind. - EXPECT_FALSE(prov::def_of(prov::Kind::HostModule).bareAddressable); -} + expect(!(prov::def_of(prov::Kind::HostModule).bareAddressable)); +}}; // ── Propagation ──────────────────────────────────────────────────────────── -TEST(Provisions, ToolIsVisibleToTheRequesterWithoutAnyReexport) { +UTest tool_is_visible_to_the_requester_without_any_reexport{"Provisions/ToolIsVisibleToTheRequesterWithoutAnyReexport", [] { // 0=root, 1=protobuf. root --tools=[protoc]--> protobuf std::vector edges{ { 0, 1, { "protoc" }, false, false, false } }; auto p = prov::propagate(edges, 2); - EXPECT_TRUE(sees_tool(p, 0, 1, "protoc")); + expect(bool(sees_tool(p, 0, 1, "protoc"))); // Nothing to export: the root has no consumers, and it never said it // re-exported anything anyway. - EXPECT_TRUE(p.exported[0].empty()); -} + expect(p.exported[0].empty()); +}}; -TEST(Provisions, WithoutReexportALibrarysToolStaysWithTheLibrary) { +UTest without_reexport_a_librarys_tool_stays_with_the_library{"Provisions/WithoutReexportALibrarysToolStaysWithTheLibrary", [] { // 0=app, 1=lib, 2=protobuf. app -> lib --tools=[protoc]--> protobuf std::vector edges{ { 0, 1, {}, false, false, false }, { 1, 2, { "protoc" }, false, false, /*reexport=*/false }, }; auto p = prov::propagate(edges, 3); - EXPECT_TRUE(sees_tool(p, 1, 2, "protoc")); + expect(bool(sees_tool(p, 1, 2, "protoc"))); // THE test: an ordinary dependency must not push entries into its // consumer's tool namespace. That is a supply-chain rule, not a // convenience — which is why `reexport` defaults to false. - EXPECT_FALSE(sees_tool(p, 0, 2, "protoc")); -} + expect(!sees_tool(p, 0, 2, "protoc")); +}}; -TEST(Provisions, ReexportHandsTheToolToTheLibrarysConsumer) { +UTest reexport_hands_the_tool_to_the_librarys_consumer{"Provisions/ReexportHandsTheToolToTheLibrarysConsumer", [] { std::vector edges{ { 0, 1, {}, false, false, false }, { 1, 2, { "protoc" }, false, false, /*reexport=*/true }, }; auto p = prov::propagate(edges, 3); - EXPECT_TRUE(sees_tool(p, 0, 2, "protoc")); - EXPECT_TRUE(sees_tool(p, 1, 2, "protoc")); -} + expect(bool(sees_tool(p, 0, 2, "protoc"))); + expect(bool(sees_tool(p, 1, 2, "protoc"))); +}}; -TEST(Provisions, ReexportTravelsFurtherOnlyWhenEachHopSaysSo) { +UTest reexport_travels_further_only_when_each_hop_says_so{"Provisions/ReexportTravelsFurtherOnlyWhenEachHopSaysSo", [] { // 0=app, 1=mid, 2=lib, 3=protobuf. // lib re-exports protoc to mid; mid does NOT re-export to app. std::vector edges{ @@ -109,30 +111,30 @@ TEST(Provisions, ReexportTravelsFurtherOnlyWhenEachHopSaysSo) { { 2, 3, { "protoc" }, false, false, /*reexport=*/true }, }; auto p = prov::propagate(edges, 4); - EXPECT_TRUE(sees_tool(p, 2, 3, "protoc")); - EXPECT_TRUE(sees_tool(p, 1, 3, "protoc")); - EXPECT_FALSE(sees_tool(p, 0, 3, "protoc")); + expect(bool(sees_tool(p, 2, 3, "protoc"))); + expect(bool(sees_tool(p, 1, 3, "protoc"))); + expect(!sees_tool(p, 0, 3, "protoc")); // Flip the middle hop and it reaches all the way. Each package decides // what IT hands to ITS consumers; nothing decides on someone else's // behalf. edges[1].reexport = true; auto q = prov::propagate(edges, 4); - EXPECT_TRUE(sees_tool(q, 0, 3, "protoc")); -} + expect(bool(sees_tool(q, 0, 3, "protoc"))); +}}; -TEST(Provisions, HostModuleFollowsTheSameRule) { +UTest host_module_follows_the_same_rule{"Provisions/HostModuleFollowsTheSameRule", [] { std::vector edges{ { 0, 1, {}, false, false, false }, { 1, 2, {}, /*buildTable=*/true, /*wantsLibrary=*/true, /*reexport=*/true }, }; auto p = prov::propagate(edges, 3); const prov::Provision rule{ prov::Kind::HostModule, 2, {} }; - EXPECT_TRUE(p.visible[0].contains(rule)); - EXPECT_TRUE(p.visible[1].contains(rule)); -} + expect(p.visible[0].contains(rule)); + expect(p.visible[1].contains(rule)); +}}; -TEST(Provisions, EveryEdgeExposesTheDependencyDirectory) { +UTest every_edge_exposes_the_dependency_directory{"Provisions/EveryEdgeExposesTheDependencyDirectory", [] { // dep_dir() has always worked for a DIRECT dependency without anyone // declaring anything; that stays true. Re-export is what extends it. std::vector edges{ @@ -141,13 +143,13 @@ TEST(Provisions, EveryEdgeExposesTheDependencyDirectory) { { 1, 3, {}, false, false, /*reexport=*/false }, }; auto p = prov::propagate(edges, 4); - EXPECT_TRUE(sees_dir(p, 0, 1)); - EXPECT_TRUE(sees_dir(p, 0, 2)); - EXPECT_FALSE(sees_dir(p, 0, 3)); - EXPECT_TRUE(sees_dir(p, 1, 3)); -} + expect(bool(sees_dir(p, 0, 1))); + expect(bool(sees_dir(p, 0, 2))); + expect(!sees_dir(p, 0, 3)); + expect(bool(sees_dir(p, 1, 3))); +}}; -TEST(Provisions, ACycleTerminates) { +UTest a_cycle_terminates{"Provisions/ACycleTerminates", [] { // Resolution should not produce one, but the fixpoint must not depend on // that: sets only grow and are bounded, so a cycle simply stops changing. std::vector edges{ @@ -155,58 +157,58 @@ TEST(Provisions, ACycleTerminates) { { 1, 0, { "b" }, false, false, true }, }; auto p = prov::propagate(edges, 2); - EXPECT_TRUE(sees_tool(p, 0, 1, "a")); - EXPECT_TRUE(sees_tool(p, 1, 0, "b")); -} + expect(bool(sees_tool(p, 0, 1, "a"))); + expect(bool(sees_tool(p, 1, 0, "b"))); +}}; // ── Bare-name binding ────────────────────────────────────────────────────── -TEST(Provisions, ALoneCandidateOwnsItsBareName) { +UTest a_lone_candidate_owns_its_bare_name{"Provisions/ALoneCandidateOwnsItsBareName", [] { auto b = prov::bind_bare_names({ "compat.protobuf" }); - ASSERT_TRUE(b.contains("protobuf")); - EXPECT_EQ(b["protobuf"].owner, "compat.protobuf"); - EXPECT_FALSE(b["protobuf"].contested); - EXPECT_TRUE(prov::contest_note("protobuf", b["protobuf"]).empty()); -} + expect(fatal(b.contains("protobuf"))); + expect(eq(b["protobuf"].owner, "compat.protobuf")); + expect(!(b["protobuf"].contested)); + expect(prov::contest_note("protobuf", b["protobuf"]).empty()); +}}; -TEST(Provisions, ANonDefaultNamespaceStillGetsItsBareName) { +UTest a_non_default_namespace_still_gets_its_bare_name{"Provisions/ANonDefaultNamespaceStillGetsItsBareName", [] { // grpc-m's rule calls dep_bin("grpc-plugin", …); (grpc, grpc-plugin) is on // no rung of the package-identity ladder, so without the // unique-candidate rung the spelling already in the wild would break. auto b = prov::bind_bare_names({ "grpc.grpc-plugin" }); - EXPECT_EQ(b["grpc-plugin"].owner, "grpc.grpc-plugin"); -} + expect(eq(b["grpc-plugin"].owner, "grpc.grpc-plugin")); +}}; -TEST(Provisions, TheLadderPicksTheDefaultNamespaceFirst) { +UTest the_ladder_picks_the_default_namespace_first{"Provisions/TheLadderPicksTheDefaultNamespaceFirst", [] { auto b = prov::bind_bare_names({ "compat.zlib", "mcpplibs.zlib", "acme.zlib" }); - EXPECT_EQ(b["zlib"].owner, "mcpplibs.zlib"); - EXPECT_TRUE(b["zlib"].contested); + expect(eq(b["zlib"].owner, "mcpplibs.zlib")); + expect(bool(b["zlib"].contested)); // Contested but bound: the user is told which one won and how to name the // other, rather than getting whichever the loop appended last. auto note = prov::contest_note("zlib", b["zlib"]); - EXPECT_NE(note.find("mcpplibs.zlib"), std::string::npos); - EXPECT_NE(note.find("acme.zlib"), std::string::npos); -} + expect(ne(note.find("mcpplibs.zlib"), std::string::npos)); + expect(ne(note.find("acme.zlib"), std::string::npos)); +}}; -TEST(Provisions, CompatIsTheSecondRung) { +UTest compat_is_the_second_rung{"Provisions/CompatIsTheSecondRung", [] { auto b = prov::bind_bare_names({ "compat.zlib", "acme.zlib" }); - EXPECT_EQ(b["zlib"].owner, "compat.zlib"); -} + expect(eq(b["zlib"].owner, "compat.zlib")); +}}; -TEST(Provisions, TwoUnreachableNamespacesLeaveTheBareNameUnbound) { +UTest two_unreachable_namespaces_leave_the_bare_name_unbound{"Provisions/TwoUnreachableNamespacesLeaveTheBareNameUnbound", [] { // Neither is on the ladder and neither is unique. Binding either one would // be an arbitrary choice that decides which binary runs. auto b = prov::bind_bare_names({ "acme.protoc-ish", "other.protoc-ish" }); - EXPECT_TRUE(b["protoc-ish"].owner.empty()); - EXPECT_TRUE(b["protoc-ish"].contested); + expect(b["protoc-ish"].owner.empty()); + expect(bool(b["protoc-ish"].contested)); auto note = prov::contest_note("protoc-ish", b["protoc-ish"]); - EXPECT_NE(note.find("NOT"), std::string::npos); -} + expect(ne(note.find("NOT"), std::string::npos)); +}}; -TEST(Provisions, AnUnnamespacedPackageIsTheThirdRung) { +UTest an_unnamespaced_package_is_the_third_rung{"Provisions/AnUnnamespacedPackageIsTheThirdRung", [] { auto b = prov::bind_bare_names({ "grpcgen", "acme.grpcgen" }); - EXPECT_EQ(b["grpcgen"].owner, "grpcgen"); -} + expect(eq(b["grpcgen"].owner, "grpcgen")); +}}; // ── Host module identity ─────────────────────────────────────────────────── // @@ -229,41 +231,40 @@ std::filesystem::path write_iface(std::string_view stem, std::string_view body) } // namespace -TEST(HostModuleIdentity, TheDeclaredNameWinsOverThePackageName) { +UTest the_declared_name_wins_over_the_package_name{"HostModuleIdentity/TheDeclaredNameWinsOverThePackageName", [] { auto p = write_iface("declared-wins", "export module mcpp.rules.protobuf;\nimport std;\n"); - EXPECT_EQ(prov::host_module_name(p, "protobufgen"), "mcpp.rules.protobuf"); -} + expect(eq(prov::host_module_name(p, "protobufgen"), "mcpp.rules.protobuf")); +}}; -TEST(HostModuleIdentity, ADottedNameSurvivesWhole) { +UTest a_dotted_name_survives_whole{"HostModuleIdentity/ADottedNameSurvivesWhole", [] { // `declared_module_roots` is named for module ROOTS as opposed to // partitions; it must not truncate `a.b.c` to `a`, or every namespaced // rule would register under one colliding name. auto p = write_iface("dotted", "export module a.b.c;\n"); - EXPECT_EQ(prov::host_module_name(p, "fallback"), "a.b.c"); -} + expect(eq(prov::host_module_name(p, "fallback"), "a.b.c")); +}}; -TEST(HostModuleIdentity, AgreementIsTheCommonCaseAndIsUnchanged) { +UTest agreement_is_the_common_case_and_is_unchanged{"HostModuleIdentity/AgreementIsTheCommonCaseAndIsUnchanged", [] { // The zero-break claim: every rule package that works today declares a // name equal to its package name, so the scan returns what the old code // returned. auto p = write_iface("grpcgen", "export module grpcgen;\nimport std;\n"); - EXPECT_EQ(prov::host_module_name(p, "grpcgen"), "grpcgen"); -} + expect(eq(prov::host_module_name(p, "grpcgen"), "grpcgen")); +}}; -TEST(HostModuleIdentity, AnUnreadableInterfaceFallsBackToThePackageName) { +UTest an_unreadable_interface_falls_back_to_the_package_name{"HostModuleIdentity/AnUnreadableInterfaceFallsBackToThePackageName", [] { // build_host_module reports a missing interface unit by path, and that // diagnostic needs a name to report the package with. - EXPECT_EQ(prov::host_module_name("/nonexistent/nowhere.cppm", "rulepkg"), - "rulepkg"); -} + expect(eq(prov::host_module_name("/nonexistent/nowhere.cppm", "rulepkg"), "rulepkg")); +}}; -TEST(HostModuleIdentity, AnInterfaceDeclaringNoModuleFallsBack) { +UTest an_interface_declaring_no_module_falls_back{"HostModuleIdentity/AnInterfaceDeclaringNoModuleFallsBack", [] { auto p = write_iface("nomodule", "int main() { return 0; }\n"); - EXPECT_EQ(prov::host_module_name(p, "rulepkg"), "rulepkg"); -} + expect(eq(prov::host_module_name(p, "rulepkg"), "rulepkg")); +}}; -TEST(HostModuleIdentity, TwoRulesProvidingOneModuleNameAreRefused) { +UTest two_rules_providing_one_module_name_are_refused{"HostModuleIdentity/TwoRulesProvidingOneModuleNameAreRefused", [] { // Designated initialisers on purpose. Positional aggregate init put the // interface path into a field added later, and the test then asserted that // a path it had never passed was absent — a green that meant nothing until @@ -275,16 +276,16 @@ TEST(HostModuleIdentity, TwoRulesProvidingOneModuleNameAreRefused) { .interface = "/b/src/rules.cppm"}, }; auto clash = prov::host_module_collision(mods); - ASSERT_TRUE(clash.has_value()); + expect(fatal(clash.has_value())); // Both identities and both paths are the CONTENT of this diagnostic: with // one module name shared, they are the only way to tell the two apart. - EXPECT_NE(clash->find("acme.tidy"), std::string::npos); - EXPECT_NE(clash->find("other.tidy"), std::string::npos); - EXPECT_NE(clash->find("/a/src/tidy.cppm"), std::string::npos); - EXPECT_NE(clash->find("/b/src/rules.cppm"), std::string::npos); -} + expect(ne(clash->find("acme.tidy"), std::string::npos)); + expect(ne(clash->find("other.tidy"), std::string::npos)); + expect(ne(clash->find("/a/src/tidy.cppm"), std::string::npos)); + expect(ne(clash->find("/b/src/rules.cppm"), std::string::npos)); +}}; -TEST(HostModuleIdentity, DistinctModuleNamesFromOnePackageNameAreFine) { +UTest distinct_module_names_from_one_package_name_are_fine{"HostModuleIdentity/DistinctModuleNamesFromOnePackageNameAreFine", [] { // The mirror of the previous test, and the reason I1 and I3 ship together: // decoupling the names is what makes two packages able to share a package // name while differing in what they declare. @@ -294,58 +295,58 @@ TEST(HostModuleIdentity, DistinctModuleNamesFromOnePackageNameAreFine) { {.module = "lints", .package = "other.rules", .interface = "/b/src/rules.cppm"}, }; - EXPECT_FALSE(prov::host_module_collision(mods).has_value()); -} + expect(!(prov::host_module_collision(mods).has_value())); +}}; -TEST(ReservedPrefix, AnOutsidePackageClaimingMcppIsWarnedAbout) { +UTest an_outside_package_claiming_mcpp_is_warned_about{"ReservedPrefix/AnOutsidePackageClaimingMcppIsWarnedAbout", [] { auto w = prov::reserved_prefix_warning("mcpp.rules.protobuf", "acme", "acme.protobufgen"); - ASSERT_TRUE(w.has_value()); - EXPECT_NE(w->find("mcpp.rules.protobuf"), std::string::npos); - EXPECT_NE(w->find("acme.protobufgen"), std::string::npos); -} - -TEST(ReservedPrefix, TheOfficialNamespaceIsSilent) { - EXPECT_FALSE(prov::reserved_prefix_warning("mcpp.rules.protobuf", "mcpp", - "mcpp.protobuf").has_value()); -} - -TEST(ReservedPrefix, AnOrdinaryNameIsSilent) { - EXPECT_FALSE(prov::reserved_prefix_warning("grpcgen", "mcpplibs", - "mcpplibs.grpcgen").has_value()); + expect(fatal(w.has_value())); + expect(ne(w->find("mcpp.rules.protobuf"), std::string::npos)); + expect(ne(w->find("acme.protobufgen"), std::string::npos)); +}}; + +UTest the_official_namespace_is_silent{"ReservedPrefix/TheOfficialNamespaceIsSilent", [] { + expect(!(prov::reserved_prefix_warning("mcpp.rules.protobuf", "mcpp", + "mcpp.protobuf").has_value())); +}}; + +UTest an_ordinary_name_is_silent{"ReservedPrefix/AnOrdinaryNameIsSilent", [] { + expect(!(prov::reserved_prefix_warning("grpcgen", "mcpplibs", + "mcpplibs.grpcgen").has_value())); // `mcppish` is not under the `mcpp.` prefix: the dot is what makes the // claim, and a substring test would have flagged this. - EXPECT_FALSE(prov::reserved_prefix_warning("mcppish", "acme", - "acme.mcppish").has_value()); -} + expect(!(prov::reserved_prefix_warning("mcppish", "acme", + "acme.mcppish").has_value())); +}}; // #734 E10: a plugin may say it is an mcpp plugin under its own namespace; // the official second segments stay the mcpp project's. -TEST(ReservedPrefix, AnOwnNamespaceUnderMcppIsSilent) { - EXPECT_FALSE(prov::reserved_prefix_warning("mcpp.acme.protobuf", "acme", - "acme.protobufgen").has_value()); - EXPECT_FALSE(prov::reserved_prefix_warning("mcpp.acme", "acme", - "acme.gen").has_value()); - EXPECT_FALSE(prov::reserved_prefix_warning("mcpp.mcpplibs.capi.lua", "mcpplibs.capi", - "mcpplibs.capi.lua").has_value()); -} - -TEST(ReservedPrefix, EveryReservedSecondSegmentIsWarnedAbout) { +UTest an_own_namespace_under_mcpp_is_silent{"ReservedPrefix/AnOwnNamespaceUnderMcppIsSilent", [] { + expect(!(prov::reserved_prefix_warning("mcpp.acme.protobuf", "acme", + "acme.protobufgen").has_value())); + expect(!(prov::reserved_prefix_warning("mcpp.acme", "acme", + "acme.gen").has_value())); + expect(!(prov::reserved_prefix_warning("mcpp.mcpplibs.capi.lua", "mcpplibs.capi", + "mcpplibs.capi.lua").has_value())); +}}; + +UTest every_reserved_second_segment_is_warned_about{"ReservedPrefix/EveryReservedSecondSegmentIsWarnedAbout", [] { for (auto seg : prov::kReservedSecondSegments) { auto name = std::string("mcpp.") + std::string(seg) + ".x"; auto w = prov::reserved_prefix_warning(name, "acme", "acme.x"); - ASSERT_TRUE(w.has_value()) << name; - EXPECT_NE(w->find("mcpp.acme.*"), std::string::npos) << *w; + expect(fatal(w.has_value())) << name; + if (auto&& failed = expect(ne(w->find("mcpp.acme.*"), std::string::npos)); !failed) failed << *w; } -} +}}; -TEST(ReservedPrefix, AnotherNamespaceUnderMcppIsWarnedAbout) { +UTest another_namespace_under_mcpp_is_warned_about{"ReservedPrefix/AnotherNamespaceUnderMcppIsWarnedAbout", [] { auto w = prov::reserved_prefix_warning("mcpp.other.x", "acme", "acme.x"); - ASSERT_TRUE(w.has_value()); - EXPECT_NE(w->find("mcpp.acme.*"), std::string::npos) << *w; + expect(fatal(w.has_value())); + if (auto&& failed = expect(ne(w->find("mcpp.acme.*"), std::string::npos)); !failed) failed << *w; // `mcpp.acmeish` is not under `mcpp.acme.`: the segment boundary decides. - EXPECT_TRUE(prov::reserved_prefix_warning("mcpp.acmeish.x", "acme", "acme.x").has_value()); -} + expect(prov::reserved_prefix_warning("mcpp.acmeish.x", "acme", "acme.x").has_value()); +}}; // A package that offers several rules through features (mcpp 2026.9.5.3+) // contributes every module INTERFACE unit among its resolved sources. The @@ -353,30 +354,26 @@ TEST(ReservedPrefix, AnotherNamespaceUnderMcppIsWarnedAbout) { // wrong answer would turn into a compile of something that cannot be compiled // alone -- an implementation unit, a partition -- or into a phantom module // named by a comment. -TEST(InterfaceUnit, ThePrimaryInterfaceDeclarationIsRecognised) { - EXPECT_EQ(prov::declared_interface_name("export module mcpp.rules.cuda;\n"), - "mcpp.rules.cuda"); - EXPECT_EQ(prov::declared_interface_name( - "module;\n#include \nexport module a.b;\nimport std;\n"), - "a.b"); - EXPECT_EQ(prov::declared_interface_name("\texport module\tx ; // trailing\n"), - "x"); -} - -TEST(InterfaceUnit, WhatIsNotAPrimaryInterfaceIsNotNamed) { - EXPECT_EQ(prov::declared_interface_name("module a.b;\n"), ""); // implementation - EXPECT_EQ(prov::declared_interface_name("export module a.b:part;\n"), ""); // partition - EXPECT_EQ(prov::declared_interface_name("module;\n"), ""); // global fragment - EXPECT_EQ(prov::declared_interface_name("export module;\n"), ""); - EXPECT_EQ(prov::declared_interface_name("// export module a.b;\n"), ""); // documentation - EXPECT_EQ(prov::declared_interface_name("exportmodule a.b;\n"), ""); - EXPECT_EQ(prov::declared_interface_name("int x; // not a module at all\n"), ""); -} - -TEST(InterfaceUnit, TheFirstDeclarationWins) { +UTest the_primary_interface_declaration_is_recognised{"InterfaceUnit/ThePrimaryInterfaceDeclarationIsRecognised", [] { + expect(eq(prov::declared_interface_name("export module mcpp.rules.cuda;\n"), "mcpp.rules.cuda")); + expect(eq(prov::declared_interface_name( + "module;\n#include \nexport module a.b;\nimport std;\n"), "a.b")); + expect(eq(prov::declared_interface_name("\texport module\tx ; // trailing\n"), "x")); +}}; + +UTest what_is_not_a_primary_interface_is_not_named{"InterfaceUnit/WhatIsNotAPrimaryInterfaceIsNotNamed", [] { + expect(eq(prov::declared_interface_name("module a.b;\n"), "")); // implementation + expect(eq(prov::declared_interface_name("export module a.b:part;\n"), "")); // partition + expect(eq(prov::declared_interface_name("module;\n"), "")); // global fragment + expect(eq(prov::declared_interface_name("export module;\n"), "")); + expect(eq(prov::declared_interface_name("// export module a.b;\n"), "")); // documentation + expect(eq(prov::declared_interface_name("exportmodule a.b;\n"), "")); + expect(eq(prov::declared_interface_name("int x; // not a module at all\n"), "")); +}}; + +UTest the_first_declaration_wins{"InterfaceUnit/TheFirstDeclarationWins", [] { // A file names one module; anything after the first declaration is that // module's body, and a quoted declaration inside it is text. - EXPECT_EQ(prov::declared_interface_name( - "export module first;\n/* export module second; */\n"), - "first"); -} + expect(eq(prov::declared_interface_name( + "export module first;\n/* export module second; */\n"), "first")); +}}; diff --git a/tests/unit/test_run_concurrent_children.cpp b/tests/unit/test_run_concurrent_children.cpp index 8dafbed17..02c4015b8 100644 --- a/tests/unit/test_run_concurrent_children.cpp +++ b/tests/unit/test_run_concurrent_children.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpplibs.os; import mcpp.run; +using namespace mcpplibs::mtest; + // SUBSYSTEM-LEVEL, and the contract is B2-0 of the member-selection and // build-program-cost plan (#748): the launcher is safe for concurrent callers. // @@ -57,7 +59,7 @@ Outcome run_when_open(const std::atomic& gate, const std::vector gate{false}; Outcome quick, slow; @@ -67,18 +69,18 @@ TEST(ConcurrentChildren, AReaderReturnsWhenItsOwnChildExits) { tQuick.join(); tSlow.join(); - ASSERT_EQ(quick.exit_code, 0) << "round " << round << ": " << quick.output; - ASSERT_EQ(slow.exit_code, 0) << "round " << round << ": " << slow.output; + expect(fatal(eq(quick.exit_code, 0))) << "round " << round << ": " << quick.output; + expect(fatal(eq(slow.exit_code, 0))) << "round " << round << ": " << slow.output; // Each reader is handed its own child's output and nothing else. - EXPECT_NE(quick.output.find("quick"), std::string::npos) << quick.output; + expect(ne(quick.output.find("quick"), std::string::npos)) << quick.output; // The quick child was gone long before the slow one. Half the slow // child's life is the margin: it does not depend on how fast the // machine starts a process, only on the two readers not sharing a pipe. const auto gap = std::chrono::duration_cast( slow.end - quick.end); - EXPECT_GT(gap, kSlowFor / 2) + expect(gt(gap, kSlowFor / 2)) << "round " << round << ": the reader of the child that exited at once " << "returned only " << gap.count() << " ms before the slow child did; " << "it waited for a child another thread started"; } -} +}}; diff --git a/tests/unit/test_runner_lookup.cpp b/tests/unit/test_runner_lookup.cpp index 0f5a5f169..ba53d3a31 100644 --- a/tests/unit/test_runner_lookup.cpp +++ b/tests/unit/test_runner_lookup.cpp @@ -1,10 +1,12 @@ -#include #include #include import std; +import mcpplibs.mtest; import mcpp.build.runner_lookup; +using namespace mcpplibs::mtest; + using namespace mcpp::build::runner_lookup; // The lookup order is the whole point (#544 §4.4): a declared payload's bin/ @@ -29,125 +31,125 @@ std::filesystem::path make_exe(const std::filesystem::path& dir, std::string_vie } } // namespace -TEST(RunnerLookup, PayloadBinBeatsPath) { +UTest payload_bin_beats_path{"RunnerLookup/PayloadBinBeatsPath", [] { auto root = fresh_root("mcpp-runner-lookup-1"); auto inPayload = make_exe(root / "payload" / "bin", "qemu-x"); make_exe(root / "path", "qemu-x"); std::vector bins{root / "payload" / "bin"}; auto l = locate("qemu-x", bins, (root / "path").string()); - ASSERT_TRUE(l.program.has_value()); - EXPECT_EQ(*l.program, inPayload); -} + expect(fatal(l.program.has_value())); + expect(eq(*l.program, inPayload)); +}}; -TEST(RunnerLookup, PathIsSearchedAfterPayloads) { +UTest path_is_searched_after_payloads{"RunnerLookup/PathIsSearchedAfterPayloads", [] { auto root = fresh_root("mcpp-runner-lookup-2"); auto onPath = make_exe(root / "path", "qemu-y"); std::vector bins{root / "payload" / "bin"}; // absent dir auto l = locate("qemu-y", bins, (root / "path").string()); - ASSERT_TRUE(l.program.has_value()); - EXPECT_EQ(*l.program, onPath); - ASSERT_EQ(l.searched.size(), 2u); - EXPECT_EQ(l.searched[0], root / "payload" / "bin"); - EXPECT_EQ(l.searched[1], root / "path"); -} - -TEST(RunnerLookup, NonExecutableFileIsSkipped) { + expect(fatal(l.program.has_value())); + expect(eq(*l.program, onPath)); + expect(fatal(eq(l.searched.size(), 2u))); + expect(eq(l.searched[0], root / "payload" / "bin")); + expect(eq(l.searched[1], root / "path")); +}}; + +UTest non_executable_file_is_skipped{"RunnerLookup/NonExecutableFileIsSkipped", [] { auto root = fresh_root("mcpp-runner-lookup-3"); std::filesystem::create_directories(root / "p1"); { std::ofstream o(root / "p1" / "tool"); o << "data"; } // no exec bit auto real = make_exe(root / "p2", "tool"); auto l = locate("tool", {}, (root / "p1").string() + ":" + (root / "p2").string()); - ASSERT_TRUE(l.program.has_value()); - EXPECT_EQ(*l.program, real); -} + expect(fatal(l.program.has_value())); + expect(eq(*l.program, real)); +}}; -TEST(RunnerLookup, NotFoundListsEveryDirectorySearched) { +UTest not_found_lists_every_directory_searched{"RunnerLookup/NotFoundListsEveryDirectorySearched", [] { auto root = fresh_root("mcpp-runner-lookup-4"); std::vector bins{root / "a" / "bin"}; auto l = locate("nope", bins, (root / "p1").string() + ":" + (root / "p2").string()); - EXPECT_FALSE(l.program.has_value()); - ASSERT_EQ(l.searched.size(), 3u); + expect(!l.program.has_value()); + expect(fatal(eq(l.searched.size(), 3u))); auto msg = not_found_message("aarch64-linux-musl", "nope", l.searched); - EXPECT_NE(msg.find("runner 'nope'"), std::string::npos) << msg; - EXPECT_NE(msg.find("aarch64-linux-musl"), std::string::npos) << msg; - EXPECT_NE(msg.find((root / "a" / "bin").string()), std::string::npos) << msg; - EXPECT_NE(msg.find((root / "p2").string()), std::string::npos) << msg; - EXPECT_NE(msg.find("[xlings.workspace]"), std::string::npos) << msg; - EXPECT_NE(msg.find("--no-runner"), std::string::npos) << msg; -} - -TEST(RunnerLookup, AbsoluteArgv0IsTakenAsIs) { + expect(ne(msg.find("runner 'nope'"), std::string::npos)) << msg; + expect(ne(msg.find("aarch64-linux-musl"), std::string::npos)) << msg; + expect(ne(msg.find((root / "a" / "bin").string()), std::string::npos)) << msg; + expect(ne(msg.find((root / "p2").string()), std::string::npos)) << msg; + expect(ne(msg.find("[xlings.workspace]"), std::string::npos)) << msg; + expect(ne(msg.find("--no-runner"), std::string::npos)) << msg; +}}; + +UTest absolute_argv0_is_taken_as_is{"RunnerLookup/AbsoluteArgv0IsTakenAsIs", [] { auto root = fresh_root("mcpp-runner-lookup-5"); auto abs = make_exe(root, "runner.sh"); auto l = locate(abs.string(), {}, ""); - ASSERT_TRUE(l.program.has_value()); - EXPECT_EQ(*l.program, abs); + expect(fatal(l.program.has_value())); + expect(eq(*l.program, abs)); // ...and an absolute path that is not there is not searched for elsewhere. auto missing = locate((root / "absent.sh").string(), {}, root.string()); - EXPECT_FALSE(missing.program.has_value()); -} + expect(!missing.program.has_value()); +}}; -TEST(RunnerLookup, EmptyPathEntriesAreIgnored) { +UTest empty_path_entries_are_ignored{"RunnerLookup/EmptyPathEntriesAreIgnored", [] { auto root = fresh_root("mcpp-runner-lookup-6"); auto onPath = make_exe(root / "p", "tool"); auto l = locate("tool", {}, ":" + (root / "p").string() + "::"); - ASSERT_TRUE(l.program.has_value()); - EXPECT_EQ(*l.program, onPath); - EXPECT_EQ(l.searched.size(), 1u); -} - -TEST(RunnerLookup, ClassifiesENOEXECAsUnloadable) { - EXPECT_EQ(classify(ENOEXEC), SpawnClass::Unloadable); - EXPECT_EQ(classify(EACCES), SpawnClass::Other); - EXPECT_EQ(classify(ENOENT), SpawnClass::Other); - EXPECT_EQ(classify(0), SpawnClass::Other); -} - -TEST(RunnerLookup, UnrunnableMessageNamesKernelAnswerTripleAndKey) { + expect(fatal(l.program.has_value())); + expect(eq(*l.program, onPath)); + expect(eq(l.searched.size(), 1u)); +}}; + +UTest classifies_enoexec_as_unloadable{"RunnerLookup/ClassifiesENOEXECAsUnloadable", [] { + expect(eq(classify(ENOEXEC), SpawnClass::Unloadable)); + expect(eq(classify(EACCES), SpawnClass::Other)); + expect(eq(classify(ENOENT), SpawnClass::Other)); + expect(eq(classify(0), SpawnClass::Other)); +}}; + +UTest unrunnable_message_names_kernel_answer_triple_and_key{"RunnerLookup/UnrunnableMessageNamesKernelAnswerTripleAndKey", [] { auto msg = unrunnable_message("aarch64-linux-musl", "/x/bin/app", ENOEXEC); - EXPECT_NE(msg.find("Exec format error"), std::string::npos) << msg; - EXPECT_NE(msg.find("/x/bin/app"), std::string::npos) << msg; - EXPECT_NE(msg.find("built for 'aarch64-linux-musl'"), std::string::npos) << msg; - EXPECT_NE(msg.find("[target.aarch64-linux-musl]"), std::string::npos) << msg; - EXPECT_NE(msg.find("runner = [\"qemu-aarch64-static\"]"), std::string::npos) << msg; - EXPECT_NE(msg.find("--no-runner"), std::string::npos) << msg; -} + expect(ne(msg.find("Exec format error"), std::string::npos)) << msg; + expect(ne(msg.find("/x/bin/app"), std::string::npos)) << msg; + expect(ne(msg.find("built for 'aarch64-linux-musl'"), std::string::npos)) << msg; + expect(ne(msg.find("[target.aarch64-linux-musl]"), std::string::npos)) << msg; + expect(ne(msg.find("runner = [\"qemu-aarch64-static\"]"), std::string::npos)) << msg; + expect(ne(msg.find("--no-runner"), std::string::npos)) << msg; +}}; // A PAYLOAD CONTRIBUTES TWO DIRECTORIES, AND THE ORDER IS PART OF THE RULE. // The flat case is the one measured missing on a macOS runner: a program at // the payload root with no `bin/`, reached through exactly the directories // prepare collects for each installed payload. -TEST(RunnerLookup, AFlatPayloadIsFoundAtItsRoot) { +UTest a_flat_payload_is_found_at_its_root{"RunnerLookup/AFlatPayloadIsFoundAtItsRoot", [] { auto root = fresh_root("mcpp-runner-lookup-flat"); auto flat = make_exe(root / "payload", "simctl-run"); auto l = locate("simctl-run", payload_search_dirs(root / "payload"), ""); - ASSERT_TRUE(l.program.has_value()); - EXPECT_EQ(*l.program, flat); - ASSERT_EQ(l.searched.size(), 2u); - EXPECT_EQ(l.searched[0], root / "payload" / "bin"); - EXPECT_EQ(l.searched[1], root / "payload"); -} + expect(fatal(l.program.has_value())); + expect(eq(*l.program, flat)); + expect(fatal(eq(l.searched.size(), 2u))); + expect(eq(l.searched[0], root / "payload" / "bin")); + expect(eq(l.searched[1], root / "payload")); +}}; // And a recipe that follows the convention is unaffected by the second entry. -TEST(RunnerLookup, APayloadsBinIsTriedBeforeItsRoot) { +UTest a_payloads_bin_is_tried_before_its_root{"RunnerLookup/APayloadsBinIsTriedBeforeItsRoot", [] { auto root = fresh_root("mcpp-runner-lookup-binfirst"); auto inBin = make_exe(root / "payload" / "bin", "tool"); make_exe(root / "payload", "tool"); auto l = locate("tool", payload_search_dirs(root / "payload"), ""); - ASSERT_TRUE(l.program.has_value()); - EXPECT_EQ(*l.program, inBin); -} + expect(fatal(l.program.has_value())); + expect(eq(*l.program, inBin)); +}}; -TEST(RunnerLookup, SpawnFailedMessageIsVerbatim) { +UTest spawn_failed_message_is_verbatim{"RunnerLookup/SpawnFailedMessageIsVerbatim", [] { auto msg = spawn_failed_message("/x/bin/qemu", EACCES); - EXPECT_NE(msg.find("'/x/bin/qemu' could not be started"), std::string::npos) << msg; - EXPECT_NE(msg.find("Permission denied"), std::string::npos) << msg; - EXPECT_NE(msg.find("(error 13)"), std::string::npos) << msg; - EXPECT_EQ(msg.find("runner = ["), std::string::npos) << msg; // no runner advice -} + expect(ne(msg.find("'/x/bin/qemu' could not be started"), std::string::npos)) << msg; + expect(ne(msg.find("Permission denied"), std::string::npos)) << msg; + expect(ne(msg.find("(error 13)"), std::string::npos)) << msg; + expect(eq(msg.find("runner = ["), std::string::npos)) << msg; // no runner advice +}}; #else -TEST(RunnerLookup, WindowsCoveredByIntegration) { SUCCEED(); } +UTest windows_covered_by_integration{"RunnerLookup/WindowsCoveredByIntegration", [] { /* passes */; }}; #endif diff --git a/tests/unit/test_runtime_binding_fallback.cpp b/tests/unit/test_runtime_binding_fallback.cpp index b61e0d958..2bd3cc959 100644 --- a/tests/unit/test_runtime_binding_fallback.cpp +++ b/tests/unit/test_runtime_binding_fallback.cpp @@ -14,11 +14,13 @@ // the compiler's PT_INTERP) are all things some earlier mechanism wrote, and a // machine can legitimately have none of them. -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.post_install; +using namespace mcpplibs::mtest; + namespace tc = mcpp::toolchain; namespace { @@ -39,32 +41,32 @@ struct Home { ~Home() { std::error_code ec; std::filesystem::remove_all(root, ec); } }; -TEST(RuntimeBindingFallback, OnePayloadIsAnAnswer) { +UTest one_payload_is_an_answer{"RuntimeBindingFallback/OnePayloadIsAnAnswer", [] { Home h{"2.39"}; - EXPECT_EQ(tc::baked_runtime_binding(h.compiler), "glibc@2.39"); -} + expect(eq(tc::baked_runtime_binding(h.compiler), "glibc@2.39")); +}}; // The incident, in one assertion. Two payloads and nothing declaring which: // silence, not a coin flip. -TEST(RuntimeBindingFallback, TwoPayloadsIsSilence) { +UTest two_payloads_is_silence{"RuntimeBindingFallback/TwoPayloadsIsSilence", [] { Home h{"2.39", "2.44"}; - EXPECT_EQ(tc::baked_runtime_binding(h.compiler), ""); -} + expect(eq(tc::baked_runtime_binding(h.compiler), "")); +}}; -TEST(RuntimeBindingFallback, NoPayloadIsSilence) { +UTest no_payload_is_silence{"RuntimeBindingFallback/NoPayloadIsSilence", [] { Home h{}; - EXPECT_EQ(tc::baked_runtime_binding(h.compiler), ""); -} + expect(eq(tc::baked_runtime_binding(h.compiler), "")); +}}; // A compiler outside any xpkgs tree (a system gcc) has no payload set to read, // and must not acquire one from somewhere else. -TEST(RuntimeBindingFallback, NonPayloadCompilerGetsNothing) { - EXPECT_EQ(tc::baked_runtime_binding("/usr/bin/g++"), ""); -} +UTest non_payload_compiler_gets_nothing{"RuntimeBindingFallback/NonPayloadCompilerGetsNothing", [] { + expect(eq(tc::baked_runtime_binding("/usr/bin/g++"), "")); +}}; -TEST(RuntimeBindingFallback, EmptyPathIsSilence) { - EXPECT_EQ(tc::baked_runtime_binding({}), ""); -} +UTest empty_path_is_silence{"RuntimeBindingFallback/EmptyPathIsSilence", [] { + expect(eq(tc::baked_runtime_binding({}), "")); +}}; // A record that names a payload which is no longer installed. // @@ -97,11 +99,11 @@ struct HomeWithSpecs { ~HomeWithSpecs() { std::error_code ec; std::filesystem::remove_all(root, ec); } }; -TEST(RuntimeBindingFallback, RecordNamingAnAbsentPayloadIsSkipped) { +UTest record_naming_an_absent_payload_is_skipped{"RuntimeBindingFallback/RecordNamingAnAbsentPayloadIsSkipped", [] { HomeWithSpecs h{{"2.44"}, "2.39"}; // Not "glibc@2.39": that payload is gone. The singleton rule answers. - EXPECT_EQ(tc::baked_runtime_binding(h.compiler), "glibc@2.44"); -} + expect(eq(tc::baked_runtime_binding(h.compiler), "glibc@2.44")); +}}; // Linux only, and not as an exemption -- the mechanism does not exist // elsewhere. detect_baked_loader parses GCC specs, whose loader paths are @@ -114,19 +116,19 @@ TEST(RuntimeBindingFallback, RecordNamingAnAbsentPayloadIsSkipped) { // // The other cases in this file are platform-neutral (they exercise the // payload set, not the parser) and keep running everywhere. -TEST(RuntimeBindingFallback, RecordNamingAPresentPayloadWins) { +UTest record_naming_a_present_payload_wins{"RuntimeBindingFallback/RecordNamingAPresentPayloadWins", [] { if constexpr (!std::filesystem::path::preferred_separator || std::filesystem::path::preferred_separator != '/') - GTEST_SKIP() << "GCC specs loader paths are POSIX; no such record here"; + skip("GCC specs loader paths are POSIX; no such record here"); HomeWithSpecs h{{"2.39", "2.44"}, "2.39"}; // Two installed, so the singleton rule cannot answer -- but the record // names one that IS there, and that is what the artifact would load. - EXPECT_EQ(tc::baked_runtime_binding(h.compiler), "glibc@2.39"); -} + expect(eq(tc::baked_runtime_binding(h.compiler), "glibc@2.39")); +}}; -TEST(RuntimeBindingFallback, StaleRecordWithNothingToFallBackOnIsSilence) { +UTest stale_record_with_nothing_to_fall_back_on_is_silence{"RuntimeBindingFallback/StaleRecordWithNothingToFallBackOnIsSilence", [] { HomeWithSpecs h{{"2.39", "2.44"}, "2.28"}; - EXPECT_EQ(tc::baked_runtime_binding(h.compiler), ""); -} + expect(eq(tc::baked_runtime_binding(h.compiler), "")); +}}; } // namespace diff --git a/tests/unit/test_runtime_contract.cpp b/tests/unit/test_runtime_contract.cpp index 5b89c3ea3..f1b8b7f2f 100644 --- a/tests/unit/test_runtime_contract.cpp +++ b/tests/unit/test_runtime_contract.cpp @@ -1,6 +1,6 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.plan; import mcpp.manifest.toml; import mcpp.manifest.types; @@ -12,6 +12,8 @@ import mcpp.runtime.search; import mcpp.runtime.binding; import mcpp.provider.xlings.subos_info; +using namespace mcpplibs::mtest; + namespace build = mcpp::build; namespace mf = mcpp::manifest; @@ -63,7 +65,7 @@ mcpp::modgraph::PackageRoot package( return out; } -TEST(RuntimeContract, TomlReadsStructuredValuesAndKeepsLegacyFields) { +UTest toml_reads_structured_values_and_keeps_legacy_fields{"RuntimeContract/TomlReadsStructuredValuesAndKeepsLegacyFields", [] { auto parsed = mf::parse_string(R"( [package] name = "consumer" @@ -88,25 +90,23 @@ artifacts = [ { role = "library", path = "run/libwidget.so.1", provenance = "payload", abi = "elf-x86_64", digest = "sha256:abc", host_fingerprint = "host-1" }, ] )"); - ASSERT_TRUE(parsed) << parsed.error().format(); - ASSERT_EQ(parsed->runtimeConfig.requirements.size(), 2u); - EXPECT_EQ(parsed->runtimeConfig.requirements[0].kind, "capability"); - EXPECT_EQ(parsed->runtimeConfig.requirements[0].value, "display.present"); - EXPECT_TRUE(parsed->runtimeConfig.requirements[0].required); - EXPECT_FALSE(parsed->runtimeConfig.requirements[1].required); - ASSERT_EQ(parsed->runtimeConfig.artifacts.size(), 1u); - EXPECT_EQ(parsed->runtimeConfig.artifacts[0].role, "library"); - EXPECT_EQ(parsed->runtimeConfig.artifacts[0].hostFingerprint, "host-1"); - EXPECT_EQ(parsed->runtimeConfig.linkIntent.libraries, - std::vector{"widget"}); - EXPECT_EQ(parsed->runtimeConfig.linkIntent.runtimeSearchDirs, - std::vector{"run"}); - EXPECT_EQ(parsed->runtimeConfig.libraryDirs[0], "legacy/run"); - EXPECT_EQ(parsed->runtimeConfig.capabilities[0], "legacy.display"); - EXPECT_EQ(parsed->runtimeConfig.provides[0], "legacy.provider"); -} - -TEST(RuntimeContract, XpkgReadsTheSameStructuredContract) { + if (auto&& failed = expect(fatal(bool(parsed))); !failed) failed << parsed.error().format(); + expect(fatal(eq(parsed->runtimeConfig.requirements.size(), 2u))); + expect(eq(parsed->runtimeConfig.requirements[0].kind, "capability")); + expect(eq(parsed->runtimeConfig.requirements[0].value, "display.present")); + expect(bool(parsed->runtimeConfig.requirements[0].required)); + expect(!(parsed->runtimeConfig.requirements[1].required)); + expect(fatal(eq(parsed->runtimeConfig.artifacts.size(), 1u))); + expect(eq(parsed->runtimeConfig.artifacts[0].role, "library")); + expect(eq(parsed->runtimeConfig.artifacts[0].hostFingerprint, "host-1")); + expect(eq(parsed->runtimeConfig.linkIntent.libraries, std::vector{"widget"})); + expect(eq(parsed->runtimeConfig.linkIntent.runtimeSearchDirs, std::vector{"run"})); + expect(eq(parsed->runtimeConfig.libraryDirs[0], "legacy/run")); + expect(eq(parsed->runtimeConfig.capabilities[0], "legacy.display")); + expect(eq(parsed->runtimeConfig.provides[0], "legacy.provider")); +}}; + +UTest xpkg_reads_the_same_structured_contract{"RuntimeContract/XpkgReadsTheSameStructuredContract", [] { constexpr auto lua = R"( package = { spec = "1", @@ -134,16 +134,15 @@ package = { )"; auto parsed = mf::synthesize_from_xpkg_lua( lua, "backend", "2.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(parsed) << parsed.error().format(); - ASSERT_EQ(parsed->runtimeConfig.requirements.size(), 1u); - EXPECT_EQ(parsed->runtimeConfig.requirements[0].value, "display.present"); - ASSERT_EQ(parsed->runtimeConfig.artifacts.size(), 1u); - EXPECT_EQ(parsed->runtimeConfig.artifacts[0].path, "lib/libbackend.so"); - EXPECT_EQ(parsed->runtimeConfig.linkIntent.transitiveNeededDirs, - std::vector{"closure"}); -} - -TEST(RuntimeContract, RequirementsNeverSelfProvideAndCanonicalIdsDoNotCollide) { + if (auto&& failed = expect(fatal(bool(parsed))); !failed) failed << parsed.error().format(); + expect(fatal(eq(parsed->runtimeConfig.requirements.size(), 1u))); + expect(eq(parsed->runtimeConfig.requirements[0].value, "display.present")); + expect(fatal(eq(parsed->runtimeConfig.artifacts.size(), 1u))); + expect(eq(parsed->runtimeConfig.artifacts[0].path, "lib/libbackend.so")); + expect(eq(parsed->runtimeConfig.linkIntent.transitiveNeededDirs, std::vector{"closure"})); +}}; + +UTest requirements_never_self_provide_and_canonical_ids_do_not_collide{"RuntimeContract/RequirementsNeverSelfProvideAndCanonicalIdsDoNotCollide", [] { auto base = std::filesystem::temp_directory_path() / "mcpp-runtime-contract"; auto consumer = package(base / "consumer", "mcpplibs", "consumer", "1.0.0", "path+consumer"); @@ -173,35 +172,31 @@ TEST(RuntimeContract, RequirementsNeverSelfProvideAndCanonicalIdsDoNotCollide) { }); auto contract = build::resolve_runtime_contract({consumer, alpha, beta}); - ASSERT_EQ(contract.requirements.size(), 2u); - EXPECT_EQ(contract.requirements[0].requester.canonical(), - "mcpplibs.consumer@1.0.0"); - EXPECT_EQ(contract.requirements[0].requester.sourceProvenance, - "path+consumer"); - - ASSERT_EQ(contract.providers.size(), 2u); - EXPECT_EQ(contract.providers[0].provider.canonical(), "alpha.backend@2.0.0"); - EXPECT_EQ(contract.providers[1].provider.canonical(), "beta.backend@3.0.0"); - EXPECT_NE(contract.providers[0].provider, contract.providers[1].provider); - EXPECT_TRUE(std::ranges::none_of(contract.providers, [](auto const& provider) { + expect(fatal(eq(contract.requirements.size(), 2u))); + expect(eq(contract.requirements[0].requester.canonical(), "mcpplibs.consumer@1.0.0")); + expect(eq(contract.requirements[0].requester.sourceProvenance, "path+consumer")); + + expect(fatal(eq(contract.providers.size(), 2u))); + expect(eq(contract.providers[0].provider.canonical(), "alpha.backend@2.0.0")); + expect(eq(contract.providers[1].provider.canonical(), "beta.backend@3.0.0")); + expect(ne(contract.providers[0].provider, contract.providers[1].provider)); + expect(std::ranges::none_of(contract.providers, [](auto const& provider) { return provider.provider.canonical() == "mcpplibs.consumer@1.0.0"; })); - ASSERT_EQ(contract.artifacts.size(), 2u); - EXPECT_EQ(contract.artifacts[0].provider.canonical(), "alpha.backend@2.0.0"); - EXPECT_EQ(contract.artifacts[1].provider.canonical(), "beta.backend@3.0.0"); - EXPECT_EQ(contract.artifacts[0].path, - (base / "alpha/lib/libbackend.so").lexically_normal()); - ASSERT_EQ(contract.linkIntent.libraries.size(), 2u); - EXPECT_EQ(contract.linkIntent.libraries[0], - (base / "alpha/lib/libbackend.a").lexically_normal().string()); - EXPECT_EQ(contract.linkIntent.libraries[1], "backend"); -} + expect(fatal(eq(contract.artifacts.size(), 2u))); + expect(eq(contract.artifacts[0].provider.canonical(), "alpha.backend@2.0.0")); + expect(eq(contract.artifacts[1].provider.canonical(), "beta.backend@3.0.0")); + expect(eq(contract.artifacts[0].path, (base / "alpha/lib/libbackend.so").lexically_normal())); + expect(fatal(eq(contract.linkIntent.libraries.size(), 2u))); + expect(eq(contract.linkIntent.libraries[0], (base / "alpha/lib/libbackend.a").lexically_normal().string())); + expect(eq(contract.linkIntent.libraries[1], "backend")); +}}; -TEST(RuntimeContract, SourceOwnsNoProviderSpecificSelectionOrProbeBranch) { +UTest source_owns_no_provider_specific_selection_or_probe_branch{"RuntimeContract/SourceOwnsNoProviderSpecificSelectionOrProbeBranch", [] { auto repo = std::filesystem::path(__FILE__).parent_path().parent_path().parent_path(); auto src = repo / "src"; - ASSERT_TRUE(std::filesystem::is_directory(src)) << src; + expect(fatal(std::filesystem::is_directory(src))) << src; // Intentionally assembled so the ownership gate does not flag its own // vocabulary if this test is ever moved under src/. const std::vector providerWords = { @@ -233,8 +228,7 @@ TEST(RuntimeContract, SourceOwnsNoProviderSpecificSelectionOrProbeBranch) { || lineText.find("case ") != std::string::npos; if (!branch) continue; for (auto const& word : providerWords) { - EXPECT_EQ(lineText.find(word), std::string::npos) - << it->path() << ':' << line + if (auto&& failed = expect(eq(lineText.find(word), std::string::npos)); !failed) failed << it->path() << ':' << line << " contains provider-specific selection logic"; } } @@ -244,16 +238,15 @@ TEST(RuntimeContract, SourceOwnsNoProviderSpecificSelectionOrProbeBranch) { const auto begin = pos > 240 ? pos - 240 : 0; const auto window = code.substr(begin, 480); for (auto const& launch : launchWords) { - EXPECT_EQ(window.find(launch), std::string::npos) - << it->path() + if (auto&& failed = expect(eq(window.find(launch), std::string::npos)); !failed) failed << it->path() << " couples a provider-specific word to a launched probe"; } } } } -} +}}; -TEST(RuntimeContract, XlingsSelectedFactsPrecedeDescriptorFallbacks) { +UTest xlings_selected_facts_precede_descriptor_fallbacks{"RuntimeContract/XlingsSelectedFactsPrecedeDescriptorFallbacks", [] { build::BuildPlan plan; plan.runtimeProviders.push_back({ .capability = "render.demo", @@ -278,16 +271,14 @@ TEST(RuntimeContract, XlingsSelectedFactsPrecedeDescriptorFallbacks) { }); build::merge_runtime_binding_contract(plan, binding); - ASSERT_EQ(plan.runtimeSearch.size(), 0u) + expect(fatal(eq(plan.runtimeSearch.size(), 0u))) << "a binding with no SubOS library view contributes no farm entry"; - ASSERT_EQ(plan.runtimeProviders.size(), 2u); - EXPECT_EQ(plan.runtimeProviders.front().provider.canonical(), - "xim.selected@4.0.0"); - ASSERT_EQ(plan.runtimeArtifacts.size(), 1u); - EXPECT_EQ(plan.runtimeArtifacts[0].provider.canonical(), - "xim.selected@4.0.0"); - EXPECT_EQ(plan.runtimeArtifacts[0].hostFingerprint, "host-2"); -} + expect(fatal(eq(plan.runtimeProviders.size(), 2u))); + expect(eq(plan.runtimeProviders.front().provider.canonical(), "xim.selected@4.0.0")); + expect(fatal(eq(plan.runtimeArtifacts.size(), 1u))); + expect(eq(plan.runtimeArtifacts[0].provider.canonical(), "xim.selected@4.0.0")); + expect(eq(plan.runtimeArtifacts[0].hostFingerprint, "host-2")); +}}; } // namespace @@ -324,52 +315,52 @@ std::size_t farm_entries(const std::vector& dirs) { } // namespace -TEST(RuntimeSearchClosure, HostTargetGetsTheFarm) { +UTest host_target_gets_the_farm{"RuntimeSearchClosure/HostTargetGetsTheFarm", [] { // An EXPLICIT Linux target is a farm target from anywhere, including a // macOS or Windows runner: the guards read the target, not the runner. - EXPECT_EQ(farm_entries(build::runtime_search_closure( - plan_for("x86_64-linux-gnu"), host_binding_with_farm())), 1u); + expect(eq(farm_entries(build::runtime_search_closure( + plan_for("x86_64-linux-gnu"), host_binding_with_farm())), 1u)); // An EMPTY triple means "this host", so the answer is the host's format — // and that is the point of the ELF guard, not an exception to it. Asserting // 1 unconditionally is what failed on the macOS and Windows runners. const std::size_t expectedForHost = mcpplibs::os::is_linux ? 1u : 0u; - EXPECT_EQ(farm_entries(build::runtime_search_closure( - plan_for(""), host_binding_with_farm())), expectedForHost) + expect(eq(farm_entries(build::runtime_search_closure( + plan_for(""), host_binding_with_farm())), expectedForHost)) << "an empty triple must resolve to this host's format"; -} +}}; -TEST(RuntimeSearchClosure, CrossTargetGetsNoFarm) { +UTest cross_target_gets_no_farm{"RuntimeSearchClosure/CrossTargetGetsNoFarm", [] { // Another architecture: the farm holds x86_64 objects. - EXPECT_EQ(farm_entries(build::runtime_search_closure( - plan_for("aarch64-linux-gnu"), host_binding_with_farm())), 0u); + expect(eq(farm_entries(build::runtime_search_closure( + plan_for("aarch64-linux-gnu"), host_binding_with_farm())), 0u)); // Another C library: a glibc farm on a musl program's search path is the // payload mixing rule B exists to prevent. - EXPECT_EQ(farm_entries(build::runtime_search_closure( - plan_for("x86_64-linux-musl"), host_binding_with_farm())), 0u); + expect(eq(farm_entries(build::runtime_search_closure( + plan_for("x86_64-linux-musl"), host_binding_with_farm())), 0u)); // Another format: DT_RPATH does not exist there at all. - EXPECT_EQ(farm_entries(build::runtime_search_closure( - plan_for("x86_64-windows-gnu"), host_binding_with_farm())), 0u); - EXPECT_EQ(farm_entries(build::runtime_search_closure( - plan_for("aarch64-macos"), host_binding_with_farm())), 0u); -} + expect(eq(farm_entries(build::runtime_search_closure( + plan_for("x86_64-windows-gnu"), host_binding_with_farm())), 0u)); + expect(eq(farm_entries(build::runtime_search_closure( + plan_for("aarch64-macos"), host_binding_with_farm())), 0u)); +}}; // A MISMATCH must be proven, not assumed. An undeclared SubOS has no runtime // identity, and refusing its own library view on that basis would be exactly // the absence-read-as-contradiction this release removes elsewhere. Caught by // e2e 220, which builds against a SubOS it creates and which therefore has no // `subos_info` to declare anything. -TEST(RuntimeSearchClosure, UndeclaredBindingStillGetsItsOwnFarm) { +UTest undeclared_binding_still_gets_its_own_farm{"RuntimeSearchClosure/UndeclaredBindingStillGetsItsOwnFarm", [] { auto b = host_binding_with_farm(); b.declared = false; b.runtimeId.clear(); - EXPECT_EQ(farm_entries(build::runtime_search_closure( - plan_for("x86_64-linux-gnu"), b)), 1u); + expect(eq(farm_entries(build::runtime_search_closure( + plan_for("x86_64-linux-gnu"), b)), 1u)); // …but the architecture guard still applies: unknown libc is not a licence // to ignore everything else. - EXPECT_EQ(farm_entries(build::runtime_search_closure( - plan_for("aarch64-linux-gnu"), b)), 0u); -} + expect(eq(farm_entries(build::runtime_search_closure( + plan_for("aarch64-linux-gnu"), b)), 0u)); +}}; // ─── the Windows runtime identity ──────────────────────────────────────── // @@ -379,19 +370,19 @@ TEST(RuntimeSearchClosure, UndeclaredBindingStillGetsItsOwnFarm) { // Windows SDKs produced one cache key — the version axis simply stopped // existing one layer down. -TEST(RuntimeIdentity, ProviderIsDispatchedOnNotPatternMatched) { +UTest provider_is_dispatched_on_not_pattern_matched{"RuntimeIdentity/ProviderIsDispatchedOnNotPatternMatched", [] { namespace rt = mcpp::runtime; - EXPECT_EQ(rt::runtime_provider("glibc@2.39"), "glibc"); - EXPECT_EQ(rt::runtime_provider("ucrt@10.0.26100.0"), "ucrt"); - EXPECT_EQ(rt::runtime_provider("macos_sdk@14.0"), "macos_sdk"); + expect(eq(rt::runtime_provider("glibc@2.39"), "glibc")); + expect(eq(rt::runtime_provider("ucrt@10.0.26100.0"), "ucrt")); + expect(eq(rt::runtime_provider("macos_sdk@14.0"), "macos_sdk")); // No identity at all is a DIFFERENT answer from "an identity belonging to // some other provider", and every consumer that spelled // `starts_with("glibc@")` collapsed the two. - EXPECT_TRUE(rt::runtime_provider("").empty()); - EXPECT_TRUE(rt::runtime_provider("nonsense").empty()); -} + expect(rt::runtime_provider("").empty()); + expect(rt::runtime_provider("nonsense").empty()); +}}; -TEST(RuntimeIdentity, TheSdkVersionReachesTheContractHash) { +UTest the_sdk_version_reaches_the_contract_hash{"RuntimeIdentity/TheSdkVersionReachesTheContractHash", [] { namespace rt = mcpp::runtime; rt::RuntimeBinding a; a.platform = "windows"; @@ -401,33 +392,33 @@ TEST(RuntimeIdentity, TheSdkVersionReachesTheContractHash) { rt::bind_windows_ucrt(a, "10.0.22621.0"); rt::bind_windows_ucrt(b, "10.0.26100.0"); - EXPECT_EQ(a.runtimeId, "ucrt@10.0.22621.0"); - EXPECT_EQ(b.runtimeId, "ucrt@10.0.26100.0"); - EXPECT_NE(a.contractHash, b.contractHash) + expect(eq(a.runtimeId, "ucrt@10.0.22621.0")); + expect(eq(b.runtimeId, "ucrt@10.0.26100.0")); + expect(ne(a.contractHash, b.contractHash)) << "two SDKs produced one contract hash, so they produce one build " "cache — which is the defect this identity closes"; - EXPECT_NE(a.contractHash, "unset") << "the hash was not re-derived"; -} + expect(ne(a.contractHash, "unset")) << "the hash was not re-derived"; +}}; -TEST(RuntimeIdentity, BindingUcrtIsIdempotentAndSkipsAnEmptyVersion) { +UTest binding_ucrt_is_idempotent_and_skips_an_empty_version{"RuntimeIdentity/BindingUcrtIsIdempotentAndSkipsAnEmptyVersion", [] { namespace rt = mcpp::runtime; rt::RuntimeBinding b; b.platform = "windows"; rt::bind_windows_ucrt(b, "10.0.26100.0"); auto once = b.contractHash; rt::bind_windows_ucrt(b, "10.0.26100.0"); - EXPECT_EQ(b.contractHash, once); + expect(eq(b.contractHash, once)); // A Windows box with no SDK still builds — toolchain SELECTION has to // work there — so there is simply nothing to declare. rt::RuntimeBinding empty; empty.contractHash = "keep"; rt::bind_windows_ucrt(empty, ""); - EXPECT_TRUE(empty.runtimeId.empty()); - EXPECT_EQ(empty.contractHash, "keep"); -} + expect(empty.runtimeId.empty()); + expect(eq(empty.contractHash, "keep")); +}}; -TEST(RuntimeIdentity, UcrtIsAFloorDeclarationNotAPrivatePayload) { +UTest ucrt_is_a_floor_declaration_not_a_private_payload{"RuntimeIdentity/UcrtIsAFloorDeclarationNotAPrivatePayload", [] { // The asymmetry with glibc, pinned so it cannot be "tidied up" later. // // `glibc@2.39` binds a payload: the headers and the .so are both in it and @@ -442,15 +433,15 @@ TEST(RuntimeIdentity, UcrtIsAFloorDeclarationNotAPrivatePayload) { rt::RuntimeBinding b; b.platform = "windows"; rt::bind_windows_ucrt(b, "10.0.26100.0"); - EXPECT_TRUE(b.libc.empty()) + expect(b.libc.empty()) << "ucrt was projected into the private-libc field"; - EXPECT_FALSE(b.loader.has_value()); - EXPECT_TRUE(b.libraryDirs.empty()); -} + expect(!b.loader.has_value()); + expect(b.libraryDirs.empty()); +}}; // #615: `runtime.deploy` in a descriptor -- the same `{ from, to }` entries as // mcpp.toml, and the same path rule. -TEST(RuntimeContract, XpkgReadsRuntimeDeployEntries) { +UTest xpkg_reads_runtime_deploy_entries{"RuntimeContract/XpkgReadsRuntimeDeployEntries", [] { constexpr auto lua = R"( package = { spec = "1", @@ -471,19 +462,18 @@ package = { )"; auto parsed = mf::synthesize_from_xpkg_lua( lua, "icd", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_TRUE(parsed) << parsed.error().format(); + if (auto&& failed = expect(fatal(bool(parsed))); !failed) failed << parsed.error().format(); auto const& d = parsed->runtimeConfig.linkIntent.deploy; - ASSERT_EQ(d.size(), 2u); - EXPECT_EQ(d[0].from, std::filesystem::path("share/vulkan/icd.d/lvp_icd.json")); - EXPECT_EQ(d[0].to, "vulkan/icd.d"); - EXPECT_EQ(d[1].from, std::filesystem::path("share/readme.txt")); - EXPECT_EQ(d[1].to, "."); + expect(fatal(eq(d.size(), 2u))); + expect(eq(d[0].from, std::filesystem::path("share/vulkan/icd.d/lvp_icd.json"))); + expect(eq(d[0].to, "vulkan/icd.d")); + expect(eq(d[1].from, std::filesystem::path("share/readme.txt"))); + expect(eq(d[1].to, ".")); // The key after it is still read, so the entry loop consumed exactly its table. - EXPECT_EQ(parsed->runtimeConfig.linkIntent.deployFiles, - std::vector{"bin/backend.dll"}); -} + expect(eq(parsed->runtimeConfig.linkIntent.deployFiles, std::vector{"bin/backend.dll"})); +}}; -TEST(RuntimeContract, XpkgRuntimeDeployRefusesAnEscapingDestination) { +UTest xpkg_runtime_deploy_refuses_an_escaping_destination{"RuntimeContract/XpkgRuntimeDeployRefusesAnEscapingDestination", [] { constexpr auto lua = R"( package = { spec = "1", namespace = "acme", name = "icd", @@ -496,7 +486,6 @@ package = { )"; auto parsed = mf::synthesize_from_xpkg_lua( lua, "icd", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_FALSE(parsed); - EXPECT_NE(parsed.error().message.find("runtime.deploy[1]: `to` has a `.` or `..` component"), - std::string::npos) << parsed.error().message; -} + expect(fatal(!parsed)); + if (auto&& failed = expect(ne(parsed.error().message.find("runtime.deploy[1]: `to` has a `.` or `..` component"), std::string::npos)); !failed) failed << parsed.error().message; +}}; diff --git a/tests/unit/test_runtime_placement.cpp b/tests/unit/test_runtime_placement.cpp index f179694c1..5f182eecf 100644 --- a/tests/unit/test_runtime_placement.cpp +++ b/tests/unit/test_runtime_placement.cpp @@ -4,12 +4,14 @@ // the resolver reads versions through an injected function, so every // combination of kinds, versions and contracts is a unit test rather than a // Windows runner's. -#include import std; +import mcpplibs.mtest; import mcpp.pack.binfmt; import mcpp.build.runtime_placement; +using namespace mcpplibs::mtest; + namespace fs = std::filesystem; namespace rp = mcpp::build::runtime_placement; using mcpp::pack::binfmt::PeVersion; @@ -95,36 +97,36 @@ std::string make_pe(std::optional fileVersion, std::uint8_t linkerMaj } // namespace -TEST(PeVersionReader, ReadsTheFixedFileVersionAndTheLinkerVersion) { +UTest reads_the_fixed_file_version_and_the_linker_version{"PeVersionReader/ReadsTheFixedFileVersionAndTheLinkerVersion", [] { const auto image = make_pe(PeVersion{14, 44, 35112, 1}, 14, 44); auto v = mcpp::pack::binfmt::pe_file_version(std::string_view{image}); - ASSERT_TRUE(v.has_value()); - EXPECT_EQ(*v, (PeVersion{14, 44, 35112, 1})); - EXPECT_EQ(v->str(), "14.44.35112.1"); + expect(fatal(v.has_value())); + expect(eq(*v, (PeVersion{14, 44, 35112, 1}))); + expect(eq(v->str(), "14.44.35112.1")); auto l = mcpp::pack::binfmt::pe_linker_version(std::string_view{image}); - ASSERT_TRUE(l.has_value()); - EXPECT_EQ(l->major, 14); - EXPECT_EQ(l->minor, 44); -} + expect(fatal(l.has_value())); + expect(eq(l->major, 14)); + expect(eq(l->minor, 44)); +}}; // A reader that cannot answer does not decide: no resource section, a // truncated image and a file that is not PE all read as "unknown". -TEST(PeVersionReader, AnImageWithoutAVersionReadsAsUnknown) { +UTest an_image_without_a_version_reads_as_unknown{"PeVersionReader/AnImageWithoutAVersionReadsAsUnknown", [] { const auto noResource = make_pe(std::nullopt, 14, 0); - EXPECT_FALSE(mcpp::pack::binfmt::pe_file_version(std::string_view{noResource})); + expect(!mcpp::pack::binfmt::pe_file_version(std::string_view{noResource})); // The linker field is still there: lld-link writes 14.0. auto l = mcpp::pack::binfmt::pe_linker_version(std::string_view{noResource}); - ASSERT_TRUE(l.has_value()); - EXPECT_EQ(l->minor, 0); + expect(fatal(l.has_value())); + expect(eq(l->minor, 0)); auto truncated = make_pe(PeVersion{14, 44, 1, 0}, 14, 44); truncated.resize(0x420); - EXPECT_FALSE(mcpp::pack::binfmt::pe_file_version(std::string_view{truncated})); - EXPECT_FALSE(mcpp::pack::binfmt::pe_file_version(std::string_view{"\x7f" "ELF not a PE"})); - EXPECT_FALSE(mcpp::pack::binfmt::pe_linker_version(std::string_view{"MZ"})); -} + expect(!mcpp::pack::binfmt::pe_file_version(std::string_view{truncated})); + expect(!mcpp::pack::binfmt::pe_file_version(std::string_view{"\x7f" "ELF not a PE"})); + expect(!mcpp::pack::binfmt::pe_linker_version(std::string_view{"MZ"})); +}}; -TEST(PeVersionReader, ReadsAVersionFromAFile) { +UTest reads_a_version_from_a_file{"PeVersionReader/ReadsAVersionFromAFile", [] { const auto dir = fs::temp_directory_path() / std::format("mcpp-pever-{}", std::chrono::steady_clock::now().time_since_epoch().count()); fs::create_directories(dir); @@ -134,12 +136,12 @@ TEST(PeVersionReader, ReadsAVersionFromAFile) { out.write(image.data(), static_cast(image.size())); } auto v = mcpp::pack::binfmt::pe_file_version(dir / "vcruntime140.dll"); - ASSERT_TRUE(v.has_value()); - EXPECT_EQ(v->str(), "14.51.36231.0"); - EXPECT_FALSE(mcpp::pack::binfmt::pe_file_version(dir / "missing.dll")); + expect(fatal(v.has_value())); + expect(eq(v->str(), "14.51.36231.0")); + expect(!mcpp::pack::binfmt::pe_file_version(dir / "missing.dll")); std::error_code ec; fs::remove_all(dir, ec); -} +}}; // ── the resolver ─────────────────────────────────────────────────────────── @@ -225,7 +227,7 @@ rp::Input input_for(const Case& c, std::map>& // Every combination of the contract's rule, a declared runtime file, the // toolset's set and a dependency's set, with every version relation. The // properties are the rule of mcpp.build.runtime_placement, stated once each. -TEST(RuntimePlacement, EveryCombinationOfKindsVersionsAndContracts) { +UTest every_combination_of_kinds_versions_and_contracts{"RuntimePlacement/EveryCombinationOfKindsVersionsAndContracts", [] { int cases = 0; for (auto policy : {rp::CrtPolicy::Carry, rp::CrtPolicy::System, rp::CrtPolicy::Static, rp::CrtPolicy::NotApplicable}) @@ -248,16 +250,16 @@ TEST(RuntimePlacement, EveryCombinationOfKindsVersionsAndContracts) { const auto in = input_for(c, versions); const auto d = rp::resolve(in); ++cases; - SCOPED_TRACE(describe(c)); + /* trace: describe(c) */; // P1. One file per destination, compared without case. std::set dests; for (auto const& p : d.placed) - EXPECT_TRUE(dests.insert(rp::fold(p.dest.generic_string())).second) + expect(bool(dests.insert(rp::fold(p.dest.generic_string())).second)) << "two placements for " << p.dest.string(); // P2. An ordinary name is never touched by the runtime rule. - EXPECT_TRUE(dests.contains("bin/qt6core.dll")); + expect(dests.contains("bin/qt6core.dll")); std::vector crt; for (auto const& p : d.placed) @@ -266,20 +268,20 @@ TEST(RuntimePlacement, EveryCombinationOfKindsVersionsAndContracts) { if (policy == rp::CrtPolicy::System) { // P3. Host-coupled: no copy of the runtime from any source; a // declared one is refused; a dependency's is stated. - EXPECT_TRUE(crt.empty()); - EXPECT_EQ(!d.errors.empty(), declared.has_value()); - if (derived) EXPECT_FALSE(d.notes.empty()); + expect(crt.empty()); + expect(eq(!d.errors.empty(), declared.has_value())); + if (derived) expect(!d.notes.empty()); continue; } - EXPECT_TRUE(d.errors.empty()); + expect(d.errors.empty()); if (policy == rp::CrtPolicy::NotApplicable) { // P4. Off the MSVC ABI the names take the kind order: a // declaration, else the derived file. if (declared) { auto it = std::ranges::find_if(d.placed, [](auto const& p) { return rp::fold(p.dest.filename().string()) == "vcruntime140.dll"; }); - ASSERT_NE(it, d.placed.end()); - EXPECT_EQ(it->kind, rp::Kind::Declared); + expect(fatal(ne(it, d.placed.end()))); + expect(eq(it->kind, rp::Kind::Declared)); } continue; } @@ -288,14 +290,14 @@ TEST(RuntimePlacement, EveryCombinationOfKindsVersionsAndContracts) { if (declared) { auto it = std::ranges::find_if(crt, [](auto const* p) { return rp::fold(p->dest.filename().string()) == "vcruntime140.dll"; }); - ASSERT_NE(it, crt.end()); - EXPECT_EQ((*it)->kind, rp::Kind::Declared); + expect(fatal(ne(it, crt.end()))); + expect(eq((*it)->kind, rp::Kind::Declared)); // P6. D2: older than the toolset's runtime is a warning; an // unreadable version is a note; neither is said otherwise. const bool older = toolset && *declared == Ver::Old; - EXPECT_EQ(!d.warnings.empty(), older); + expect(eq(!d.warnings.empty(), older)); } else { - EXPECT_TRUE(d.warnings.empty()); + expect(d.warnings.empty()); } // P7. The rest of the runtime is ONE set: every non-declared runtime @@ -305,7 +307,7 @@ TEST(RuntimePlacement, EveryCombinationOfKindsVersionsAndContracts) { if (p->kind != rp::Kind::Declared) origins.insert(std::format("{}:{}", rp::to_string(p->kind), p->sources.front().parent_path().string())); - EXPECT_LE(origins.size(), 1u) << "the runtime set was mixed"; + expect(le(origins.size(), 1u)) << "the runtime set was mixed"; // P8. Which set: the toolset's, unless the dependency's is complete, // readable and strictly newer (D1); without the toolset's, the @@ -314,15 +316,15 @@ TEST(RuntimePlacement, EveryCombinationOfKindsVersionsAndContracts) { && (!toolset || (complete && *derived == Ver::New)); const bool anyPlaced = !origins.empty(); if (policy == rp::CrtPolicy::Carry) - EXPECT_TRUE(anyPlaced || (declared && !toolset)); + expect(anyPlaced || (declared && !toolset)); if (anyPlaced) { const bool fromDerived = origins.begin()->starts_with("derived:"); - EXPECT_EQ(fromDerived, derivedWins); + expect(eq(fromDerived, derivedWins)); } // P9. Under self-contained nothing is placed unless something brings // a runtime name. if (policy == rp::CrtPolicy::Static && !declared && !derived) - EXPECT_TRUE(crt.empty()); + expect(crt.empty()); // P10. A dependency's set that is not placed is stated once, as a // packaging fault; one that is placed over the toolset's is stated @@ -330,22 +332,22 @@ TEST(RuntimePlacement, EveryCombinationOfKindsVersionsAndContracts) { if (derived && toolset) { const auto fault = std::ranges::count_if(d.notes, [](auto const& n) { return n.find("does not carry the compiler's runtime") != std::string::npos; }); - EXPECT_EQ(fault, derivedWins ? 0 : 1); + expect(eq(fault, derivedWins ? 0 : 1)); if (derivedWins) - EXPECT_TRUE(std::ranges::any_of(d.notes, [](auto const& n) { + expect(std::ranges::any_of(d.notes, [](auto const& n) { return n.find("newer than the toolset's") != std::string::npos; })); } // P11. An unreadable version is said, and decides nothing. if (derived == Ver::Unreadable && toolset && complete) - EXPECT_TRUE(std::ranges::any_of(d.notes, [](auto const& n) { + expect(std::ranges::any_of(d.notes, [](auto const& n) { return n.find("could not be read") != std::string::npos; })); } - EXPECT_GT(cases, 100); -} + expect(gt(cases, 100)); +}}; // Two search directories offering one ordinary name stay two sources of one // destination (SPEC-007 R4.2): `mcpp stage` compares their bytes. -TEST(RuntimePlacement, TwoDerivedCopiesOfAnOrdinaryNameAreOneDestination) { +UTest two_derived_copies_of_an_ordinary_name_are_one_destination{"RuntimePlacement/TwoDerivedCopiesOfAnOrdinaryNameAreOneDestination", [] { rp::Input in; in.crt = rp::CrtPolicy::Carry; in.candidates.push_back({{"/a/libfoo.dll"}, "bin/libfoo.dll", rp::Kind::Derived}); @@ -353,24 +355,24 @@ TEST(RuntimePlacement, TwoDerivedCopiesOfAnOrdinaryNameAreOneDestination) { in.candidates.push_back({{"/c/libbar.dll"}, "bin/libbar.dll", rp::Kind::Derived}); in.candidates.push_back({{"/p/libbar.dll"}, "bin/libbar.dll", rp::Kind::Declared}); const auto d = rp::resolve(in); - ASSERT_EQ(d.placed.size(), 2u); + expect(fatal(eq(d.placed.size(), 2u))); auto foo = std::ranges::find_if(d.placed, [](auto const& p) { return p.dest == "bin/libfoo.dll"; }); - ASSERT_NE(foo, d.placed.end()); - EXPECT_EQ(foo->sources.size(), 2u); + expect(fatal(ne(foo, d.placed.end()))); + expect(eq(foo->sources.size(), 2u)); auto bar = std::ranges::find_if(d.placed, [](auto const& p) { return p.dest == "bin/libbar.dll"; }); - ASSERT_NE(bar, d.placed.end()); - EXPECT_EQ(bar->kind, rp::Kind::Declared); - EXPECT_EQ(bar->sources, std::vector{"/p/libbar.dll"}); -} + expect(fatal(ne(bar, d.placed.end()))); + expect(eq(bar->kind, rp::Kind::Declared)); + expect(eq(bar->sources, std::vector{"/p/libbar.dll"})); +}}; // A runtime name a declaration places under a subdirectory is not the // program's runtime and is not governed by the rule. -TEST(RuntimePlacement, ARuntimeNameInASubdirectoryIsAnOrdinaryFile) { +UTest a_runtime_name_in_a_subdirectory_is_an_ordinary_file{"RuntimePlacement/ARuntimeNameInASubdirectoryIsAnOrdinaryFile", [] { rp::Input in; in.crt = rp::CrtPolicy::System; in.candidates.push_back({{"/p/vcruntime140.dll"}, "bin/plugins/vcruntime140.dll", rp::Kind::Declared}); const auto d = rp::resolve(in); - EXPECT_TRUE(d.errors.empty()); - ASSERT_EQ(d.placed.size(), 1u); - EXPECT_EQ(d.placed.front().dest, "bin/plugins/vcruntime140.dll"); -} + expect(d.errors.empty()); + expect(fatal(eq(d.placed.size(), 1u))); + expect(eq(d.placed.front().dest, "bin/plugins/vcruntime140.dll")); +}}; diff --git a/tests/unit/test_runtime_search.cpp b/tests/unit/test_runtime_search.cpp index e978aef92..7a0d986af 100644 --- a/tests/unit/test_runtime_search.cpp +++ b/tests/unit/test_runtime_search.cpp @@ -6,11 +6,13 @@ // Everything downstream (the DT_RPATH mcpp emits, what `mcpp pack` strips, // what the closure resolver walks) is a consequence of these four answers. -#include import std; +import mcpplibs.mtest; import mcpp.runtime.search; +using namespace mcpplibs::mtest; + namespace search = mcpp::runtime::search; using search::Origin; @@ -20,114 +22,114 @@ namespace { // symlink view rewritten by every `xlings install`, a payload directory is // written once, and an already-linked artifact must not have its libc changed // out from under it by a later install. -TEST(RuntimeSearch, PayloadOutranksFarm) { - EXPECT_LT(search::rank(Origin::Payload), search::rank(Origin::SubosFarm)); - EXPECT_LT(search::rank(Origin::Package), search::rank(Origin::SubosFarm)); - EXPECT_LT(search::rank(Origin::SubosFarm), search::rank(Origin::HostDefault)); +UTest payload_outranks_farm{"RuntimeSearch/PayloadOutranksFarm", [] { + expect(lt(search::rank(Origin::Payload), search::rank(Origin::SubosFarm))); + expect(lt(search::rank(Origin::Package), search::rank(Origin::SubosFarm))); + expect(lt(search::rank(Origin::SubosFarm), search::rank(Origin::HostDefault))); // #415 — the artifact's own directory (`$ORIGIN`) sits between the packages // and the farm. This is the ordering #414 was about: with the farm ahead of // `$ORIGIN` an artifact linked one libX11 and loaded another. A pinned // payload still wins, because that is the one thing nothing re-points. - EXPECT_LT(search::rank(Origin::Package), search::rank(Origin::Artifact)); - EXPECT_LT(search::rank(Origin::Artifact), search::rank(Origin::SubosFarm)); - EXPECT_LT(search::rank(Origin::Payload), search::rank(Origin::Artifact)); -} + expect(lt(search::rank(Origin::Package), search::rank(Origin::Artifact))); + expect(lt(search::rank(Origin::Artifact), search::rank(Origin::SubosFarm))); + expect(lt(search::rank(Origin::Payload), search::rank(Origin::Artifact))); +}}; // `mcpp pack` asks this to decide what may not be baked into a distributable. // The host's own /usr/lib is the only one that is NOT this machine's private // state — relying on it is an ordinary host requirement, not a dependency on // the build box. -TEST(RuntimeSearch, MachineLocalIsEverythingButTheHostDefaults) { - EXPECT_TRUE(search::is_machine_local(Origin::Payload)); - EXPECT_TRUE(search::is_machine_local(Origin::Package)); - EXPECT_TRUE(search::is_machine_local(Origin::SubosFarm)); - EXPECT_FALSE(search::is_machine_local(Origin::HostDefault)); +UTest machine_local_is_everything_but_the_host_defaults{"RuntimeSearch/MachineLocalIsEverythingButTheHostDefaults", [] { + expect(search::is_machine_local(Origin::Payload)); + expect(search::is_machine_local(Origin::Package)); + expect(search::is_machine_local(Origin::SubosFarm)); + expect(!search::is_machine_local(Origin::HostDefault)); // `$ORIGIN` is NOT machine-local, and getting this backwards would be // worse than the gap it closes: it is resolved by the loader relative to // the artifact, so it means the same thing wherever that artifact is // copied — `pack` REWRITES everything else into this form. Marking it // local would make pack reject the one entry it is trying to produce. - EXPECT_FALSE(search::is_machine_local(Origin::Artifact)); -} + expect(!search::is_machine_local(Origin::Artifact)); +}}; // These strings are PUBLISHED — they are the `origin` field of every entry in // `resolution.json`'s `runtime.search.closure`, which CI, `mcpp why runtime` and // e2e 219 all read. Renaming one is a wire-format change, so it is pinned here // rather than left to whatever `to_string` happens to say. -TEST(RuntimeSearch, OriginNamesArePublishedAndStable) { - EXPECT_EQ(search::to_string(Origin::Payload), "payload"); - EXPECT_EQ(search::to_string(Origin::Package), "package"); - EXPECT_EQ(search::to_string(Origin::Artifact), "artifact"); - EXPECT_EQ(search::to_string(Origin::SubosFarm), "subos_farm"); - EXPECT_EQ(search::to_string(Origin::HostDefault), "host_default"); -} - -TEST(RuntimeSearch, OrderedSortsByRankNotByInsertion) { +UTest origin_names_are_published_and_stable{"RuntimeSearch/OriginNamesArePublishedAndStable", [] { + expect(eq(search::to_string(Origin::Payload), "payload")); + expect(eq(search::to_string(Origin::Package), "package")); + expect(eq(search::to_string(Origin::Artifact), "artifact")); + expect(eq(search::to_string(Origin::SubosFarm), "subos_farm")); + expect(eq(search::to_string(Origin::HostDefault), "host_default")); +}}; + +UTest ordered_sorts_by_rank_not_by_insertion{"RuntimeSearch/OrderedSortsByRankNotByInsertion", [] { std::vector input{ {"/farm", Origin::SubosFarm}, {"/pkg", Origin::Package}, {"/pay", Origin::Payload}, }; auto out = search::ordered(input); - ASSERT_EQ(out.size(), 3u); - EXPECT_EQ(out[0].path, std::filesystem::path("/pay")); - EXPECT_EQ(out[1].path, std::filesystem::path("/pkg")); - EXPECT_EQ(out[2].path, std::filesystem::path("/farm")); -} + expect(fatal(eq(out.size(), 3u))); + expect(eq(out[0].path, std::filesystem::path("/pay"))); + expect(eq(out[1].path, std::filesystem::path("/pkg"))); + expect(eq(out[2].path, std::filesystem::path("/farm"))); +}}; // Stable within a rank, because insertion order still decides among peers — // libglvnd resolves GL vendors by exactly that order, so reordering equals // would change which driver a program gets. -TEST(RuntimeSearch, OrderedIsStableWithinARank) { +UTest ordered_is_stable_within_a_rank{"RuntimeSearch/OrderedIsStableWithinARank", [] { std::vector input{ {"/pay/b", Origin::Payload}, {"/pay/a", Origin::Payload}, {"/pay/c", Origin::Payload}, }; auto out = search::ordered(input); - ASSERT_EQ(out.size(), 3u); - EXPECT_EQ(out[0].path, std::filesystem::path("/pay/b")); - EXPECT_EQ(out[1].path, std::filesystem::path("/pay/a")); - EXPECT_EQ(out[2].path, std::filesystem::path("/pay/c")); -} + expect(fatal(eq(out.size(), 3u))); + expect(eq(out[0].path, std::filesystem::path("/pay/b"))); + expect(eq(out[1].path, std::filesystem::path("/pay/a"))); + expect(eq(out[2].path, std::filesystem::path("/pay/c"))); +}}; // The same directory reachable two ways is ONE entry, and it takes the // stronger origin. A payload that is also visible through the farm view is a // payload; ranking it as farm would push the real libc behind everything else // the farm holds — the exact failure the ordering exists to prevent. -TEST(RuntimeSearch, DuplicatePathKeepsTheStrongestOrigin) { +UTest duplicate_path_keeps_the_strongest_origin{"RuntimeSearch/DuplicatePathKeepsTheStrongestOrigin", [] { std::vector input{ {"/shared", Origin::SubosFarm}, {"/shared", Origin::Payload}, {"/other", Origin::SubosFarm}, }; auto out = search::ordered(input); - ASSERT_EQ(out.size(), 2u); - EXPECT_EQ(out[0].path, std::filesystem::path("/shared")); - EXPECT_EQ(out[0].origin, Origin::Payload); - EXPECT_EQ(out[1].origin, Origin::SubosFarm); -} + expect(fatal(eq(out.size(), 2u))); + expect(eq(out[0].path, std::filesystem::path("/shared"))); + expect(eq(out[0].origin, Origin::Payload)); + expect(eq(out[1].origin, Origin::SubosFarm)); +}}; -TEST(RuntimeSearch, OrderedNormalizesAndDropsEmpty) { +UTest ordered_normalizes_and_drops_empty{"RuntimeSearch/OrderedNormalizesAndDropsEmpty", [] { std::vector input{ {"", Origin::Payload}, {"/a/./b/", Origin::Payload}, {"/a/b", Origin::Payload}, }; auto out = search::ordered(input); - ASSERT_EQ(out.size(), 1u); - EXPECT_EQ(out[0].path, std::filesystem::path("/a/b")); -} + expect(fatal(eq(out.size(), 1u))); + expect(eq(out[0].path, std::filesystem::path("/a/b"))); +}}; // The opt-out is a CROSS-REPO name (openxlings/xlings#540). Pinning the // spelling here means a rename shows up as a failed test rather than as a // silently ineffective declaration — the failure mode of an env var nobody // reads is that everything looks fine. -TEST(RuntimeSearch, LinkerOptOutIsSpelledOnce) { - EXPECT_EQ(search::kLinkerPathInjectionOptOut, "XLINGS_SUBOS_LD_PATHS"); - EXPECT_EQ(search::kLinkerPathInjectionOptOutValue, "0"); -} +UTest linker_opt_out_is_spelled_once{"RuntimeSearch/LinkerOptOutIsSpelledOnce", [] { + expect(eq(search::kLinkerPathInjectionOptOut, "XLINGS_SUBOS_LD_PATHS")); + expect(eq(search::kLinkerPathInjectionOptOutValue, "0")); +}}; } // namespace diff --git a/tests/unit/test_runtime_selection.cpp b/tests/unit/test_runtime_selection.cpp index 0b845520b..ec4830038 100644 --- a/tests/unit/test_runtime_selection.cpp +++ b/tests/unit/test_runtime_selection.cpp @@ -1,12 +1,14 @@ -#include import std; +import mcpplibs.mtest; import mcpp.config; import mcpp.manifest; import mcpplibs.os.platform; import mcpp.runtime.binding; import mcpp.provider.runtime_selection; +using namespace mcpplibs::mtest; + namespace runtime = mcpp::provider::runtime; namespace { @@ -88,105 +90,104 @@ struct RuntimeHome { } }; -TEST(RuntimeSelection, AbsenceMeansMcppDefault) { +UTest absence_means_mcpp_default{"RuntimeSelection/AbsenceMeansMcppDefault", [] { mcpp::manifest::Manifest root; auto selected = runtime::select_runtime(root, std::nullopt, "/repo"); - ASSERT_TRUE(selected.has_value()) << selected.error(); - EXPECT_EQ(selected->mode, runtime::RuntimeSelection::Mode::McppDefault); - EXPECT_EQ(selected->source, runtime::RuntimeSelection::Source::DefaultPolicy); - EXPECT_EQ(selected->subosName, "default"); - EXPECT_EQ(selected->ownerRoot, std::filesystem::path("/repo")); -} - -TEST(RuntimeSelection, ExplicitDefaultRemainsNamed) { + if (auto&& failed = expect(fatal(selected.has_value())); !failed) failed << selected.error(); + expect(eq(selected->mode, runtime::RuntimeSelection::Mode::McppDefault)); + expect(eq(selected->source, runtime::RuntimeSelection::Source::DefaultPolicy)); + expect(eq(selected->subosName, "default")); + expect(eq(selected->ownerRoot, std::filesystem::path("/repo"))); +}}; + +UTest explicit_default_remains_named{"RuntimeSelection/ExplicitDefaultRemainsNamed", [] { auto root = named("default"); auto selected = runtime::select_runtime(root, std::nullopt, "/repo"); - ASSERT_TRUE(selected.has_value()) << selected.error(); - EXPECT_EQ(selected->mode, runtime::RuntimeSelection::Mode::NamedSubos); - EXPECT_EQ(selected->source, runtime::RuntimeSelection::Source::Manifest); - EXPECT_EQ(selected->subosName, "default"); -} + if (auto&& failed = expect(fatal(selected.has_value())); !failed) failed << selected.error(); + expect(eq(selected->mode, runtime::RuntimeSelection::Mode::NamedSubos)); + expect(eq(selected->source, runtime::RuntimeSelection::Source::Manifest)); + expect(eq(selected->subosName, "default")); +}}; -TEST(RuntimeSelection, WorkspaceRootOverridesMember) { +UTest workspace_root_overrides_member{"RuntimeSelection/WorkspaceRootOverridesMember", [] { auto member = named("member-dev"); auto workspace = named("workspace-el8"); auto selected = runtime::select_runtime( member, std::cref(workspace), "/repo/member", "/repo"); - ASSERT_TRUE(selected.has_value()) << selected.error(); - EXPECT_EQ(selected->subosName, "workspace-el8"); - EXPECT_EQ(selected->ownerRoot, std::filesystem::path("/repo")); -} + if (auto&& failed = expect(fatal(selected.has_value())); !failed) failed << selected.error(); + expect(eq(selected->subosName, "workspace-el8")); + expect(eq(selected->ownerRoot, std::filesystem::path("/repo"))); +}}; -TEST(RuntimeSelection, MemberAppliesWhenItIsTheIndependentRoot) { +UTest member_applies_when_it_is_the_independent_root{"RuntimeSelection/MemberAppliesWhenItIsTheIndependentRoot", [] { auto member = named("member-dev"); auto selected = runtime::select_runtime(member, std::nullopt, "/repo/member"); - ASSERT_TRUE(selected.has_value()) << selected.error(); - EXPECT_EQ(selected->subosName, "member-dev"); - EXPECT_EQ(selected->ownerRoot, std::filesystem::path("/repo/member")); -} + if (auto&& failed = expect(fatal(selected.has_value())); !failed) failed << selected.error(); + expect(eq(selected->subosName, "member-dev")); + expect(eq(selected->ownerRoot, std::filesystem::path("/repo/member"))); +}}; -TEST(RuntimeSelection, EmptyAndPathLikeNamesAreRejected) { +UTest empty_and_path_like_names_are_rejected{"RuntimeSelection/EmptyAndPathLikeNamesAreRejected", [] { for (auto bad : {"", ".", "..", "a/b", "a\\b", "a:b"}) { auto root = named(bad); auto selected = runtime::select_runtime(root, std::nullopt, "/repo"); - EXPECT_FALSE(selected.has_value()) << bad; + expect(!selected.has_value()) << bad; } -} +}}; -TEST(RuntimeSelection, DependencyDeclarationCannotAffectConsumerSelection) { +UTest dependency_declaration_cannot_affect_consumer_selection{"RuntimeSelection/DependencyDeclarationCannotAffectConsumerSelection", [] { mcpp::manifest::Manifest consumer; auto dependency = named("dependency-only"); (void)dependency; auto selected = runtime::select_runtime(consumer, std::nullopt, "/consumer"); - ASSERT_TRUE(selected.has_value()) << selected.error(); - EXPECT_EQ(selected->mode, runtime::RuntimeSelection::Mode::McppDefault); - EXPECT_EQ(selected->subosName, "default"); -} + if (auto&& failed = expect(fatal(selected.has_value())); !failed) failed << selected.error(); + expect(eq(selected->mode, runtime::RuntimeSelection::Mode::McppDefault)); + expect(eq(selected->subosName, "default")); +}}; -TEST(RuntimeBinding, DefaultAlwaysUsesConfiguredMcppHomeDefault) { +UTest default_always_uses_configured_mcpp_home_default{"RuntimeBinding/DefaultAlwaysUsesConfiguredMcppHomeDefault", [] { RuntimeHome h; const auto expected = h.cfg.xlingsHome() / "subos" / "default"; h.write(expected); mcpp::manifest::Manifest root; auto selection = runtime::select_runtime(root, std::nullopt, h.dir / "repo"); - ASSERT_TRUE(selection); + expect(fatal(bool(selection))); auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); - ASSERT_TRUE(binding.has_value()) << binding.error(); - EXPECT_EQ(binding->subosDir, expected); - EXPECT_EQ(binding->runtimeId, "glibc@2.39"); + if (auto&& failed = expect(fatal(binding.has_value())); !failed) failed << binding.error(); + expect(eq(binding->subosDir, expected)); + expect(eq(binding->runtimeId, "glibc@2.39")); if constexpr (mcpplibs::os::is_linux) - EXPECT_EQ(binding->libc, "glibc@2.39"); + expect(eq(binding->libc, "glibc@2.39")); else - EXPECT_TRUE(binding->libc.empty()); - EXPECT_EQ(binding->provenance, "mcpp_default"); - EXPECT_FALSE(binding->contractHash.empty()); - ASSERT_EQ(binding->runtimeProviders.size(), 1u); - EXPECT_EQ(binding->runtimeProviders[0].provider.name, "renderer"); - ASSERT_EQ(binding->runtimeArtifacts.size(), 1u); - EXPECT_EQ(binding->runtimeArtifacts[0].path, - (expected / "runtime/renderer.bin").lexically_normal()); -} - -TEST(RuntimeBinding, ExplicitDefaultUsesGlobalDefaultButKeepsNamedIdentity) { + expect(binding->libc.empty()); + expect(eq(binding->provenance, "mcpp_default")); + expect(!(binding->contractHash.empty())); + expect(fatal(eq(binding->runtimeProviders.size(), 1u))); + expect(eq(binding->runtimeProviders[0].provider.name, "renderer")); + expect(fatal(eq(binding->runtimeArtifacts.size(), 1u))); + expect(eq(binding->runtimeArtifacts[0].path, (expected / "runtime/renderer.bin").lexically_normal())); +}}; + +UTest explicit_default_uses_global_default_but_keeps_named_identity{"RuntimeBinding/ExplicitDefaultUsesGlobalDefaultButKeepsNamedIdentity", [] { RuntimeHome h; const auto expected = h.cfg.xlingsHome() / "subos" / "default"; h.write(expected); auto manifest = named("default"); auto selection = runtime::select_runtime(manifest, std::nullopt, h.dir / "repo"); - ASSERT_TRUE(selection); + expect(fatal(bool(selection))); auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); - ASSERT_TRUE(binding.has_value()) << binding.error(); - EXPECT_EQ(binding->subosDir, expected); - EXPECT_EQ(binding->provenance, "named_subos"); - EXPECT_EQ(binding->selection.mode, runtime::RuntimeSelection::Mode::NamedSubos); -} + if (auto&& failed = expect(fatal(binding.has_value())); !failed) failed << binding.error(); + expect(eq(binding->subosDir, expected)); + expect(eq(binding->provenance, "named_subos")); + expect(eq(binding->selection.mode, runtime::RuntimeSelection::Mode::NamedSubos)); +}}; -TEST(RuntimeBinding, PhysicalSubosViewReconcilesAStaleDeclaredGlibcIdentity) { +UTest physical_subos_view_reconciles_a_stale_declared_glibc_identity{"RuntimeBinding/PhysicalSubosViewReconcilesAStaleDeclaredGlibcIdentity", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "glibc SubOS views only exist on Linux"; + skip("glibc SubOS views only exist on Linux"); RuntimeHome h; const auto subos = h.cfg.xlingsHome() / "subos" / "default"; @@ -195,22 +196,21 @@ TEST(RuntimeBinding, PhysicalSubosViewReconcilesAStaleDeclaredGlibcIdentity) { mcpp::manifest::Manifest root; auto selection = runtime::select_runtime(root, std::nullopt, h.dir / "repo"); - ASSERT_TRUE(selection); + expect(fatal(bool(selection))); auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); - ASSERT_TRUE(binding.has_value()) << binding.error(); - - EXPECT_EQ(binding->runtimeId, "glibc@2.44"); - EXPECT_EQ(binding->libc, "glibc@2.44"); - ASSERT_EQ(binding->libraryDirs.size(), 1u); - EXPECT_EQ(binding->libraryDirs.front(), - h.cfg.xlingsHome() / "data" / "xpkgs" - / "xim-x-glibc" / "2.44" / "lib"); -} + if (auto&& failed = expect(fatal(binding.has_value())); !failed) failed << binding.error(); + + expect(eq(binding->runtimeId, "glibc@2.44")); + expect(eq(binding->libc, "glibc@2.44")); + expect(fatal(eq(binding->libraryDirs.size(), 1u))); + expect(eq(binding->libraryDirs.front(), h.cfg.xlingsHome() / "data" / "xpkgs" + / "xim-x-glibc" / "2.44" / "lib")); +}}; -TEST(RuntimeBinding, BrokenSubosViewFallsBackOnlyToTheExactDeclaredPayload) { +UTest broken_subos_view_falls_back_only_to_the_exact_declared_payload{"RuntimeBinding/BrokenSubosViewFallsBackOnlyToTheExactDeclaredPayload", [] { if constexpr (!mcpplibs::os::is_linux) - GTEST_SKIP() << "glibc SubOS views only exist on Linux"; + skip("glibc SubOS views only exist on Linux"); RuntimeHome h; const auto subos = h.cfg.xlingsHome() / "subos" / "default"; @@ -224,39 +224,38 @@ TEST(RuntimeBinding, BrokenSubosViewFallsBackOnlyToTheExactDeclaredPayload) { mcpp::manifest::Manifest root; auto selection = runtime::select_runtime(root, std::nullopt, h.dir / "repo"); - ASSERT_TRUE(selection); + expect(fatal(bool(selection))); auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); - ASSERT_TRUE(binding.has_value()) << binding.error(); - - EXPECT_EQ(binding->runtimeId, "glibc@2.44"); - ASSERT_EQ(binding->libraryDirs.size(), 1u); - EXPECT_EQ(binding->libraryDirs.front(), payload); - EXPECT_EQ(binding->loader, - std::optional( - payload / "ld-linux-x86-64.so.2")); -} + if (auto&& failed = expect(fatal(binding.has_value())); !failed) failed << binding.error(); -TEST(RuntimeBinding, NamedSubosIsOwnedByTheRootAndMissingIsHardError) { + expect(eq(binding->runtimeId, "glibc@2.44")); + expect(fatal(eq(binding->libraryDirs.size(), 1u))); + expect(eq(binding->libraryDirs.front(), payload)); + expect(eq(binding->loader, std::optional( + payload / "ld-linux-x86-64.so.2"))); +}}; + +UTest named_subos_is_owned_by_the_root_and_missing_is_hard_error{"RuntimeBinding/NamedSubosIsOwnedByTheRootAndMissingIsHardError", [] { RuntimeHome h; auto manifest = named("el8"); auto selection = runtime::select_runtime(manifest, std::nullopt, h.dir / "repo"); - ASSERT_TRUE(selection); + expect(fatal(bool(selection))); auto missing = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); - ASSERT_FALSE(missing.has_value()); - EXPECT_NE(missing.error().find("el8"), std::string::npos); + expect(fatal(!missing.has_value())); + expect(ne(missing.error().find("el8"), std::string::npos)); const auto expected = h.dir / "repo" / ".mcpp" / ".xlings" / "subos" / "el8"; h.write(expected); auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); - ASSERT_TRUE(binding.has_value()) << binding.error(); - EXPECT_EQ(binding->subosDir, expected); -} + if (auto&& failed = expect(fatal(binding.has_value())); !failed) failed << binding.error(); + expect(eq(binding->subosDir, expected)); +}}; -TEST(RuntimeBinding, NamedEnvironmentsHaveDistinctContractsAndRoundTrip) { +UTest named_environments_have_distinct_contracts_and_round_trip{"RuntimeBinding/NamedEnvironmentsHaveDistinctContractsAndRoundTrip", [] { RuntimeHome h; auto el8Manifest = named("el8"); auto devManifest = named("dev"); @@ -264,8 +263,8 @@ TEST(RuntimeBinding, NamedEnvironmentsHaveDistinctContractsAndRoundTrip) { el8Manifest, std::nullopt, h.dir / "repo"); auto devSelection = runtime::select_runtime( devManifest, std::nullopt, h.dir / "repo"); - ASSERT_TRUE(el8Selection); - ASSERT_TRUE(devSelection); + expect(fatal(bool(el8Selection))); + expect(fatal(bool(devSelection))); h.write(h.dir / "repo" / ".mcpp" / ".xlings" / "subos" / "el8"); h.write(h.dir / "repo" / ".mcpp" / ".xlings" / "subos" / "dev"); @@ -273,29 +272,29 @@ TEST(RuntimeBinding, NamedEnvironmentsHaveDistinctContractsAndRoundTrip) { *el8Selection, {}, h.cfg.xlingsHome()); auto dev = mcpp::runtime::resolve_runtime_binding( *devSelection, {}, h.cfg.xlingsHome()); - ASSERT_TRUE(el8); - ASSERT_TRUE(dev); - EXPECT_NE(el8->contractHash, dev->contractHash); + expect(fatal(bool(el8))); + expect(fatal(bool(dev))); + expect(ne(el8->contractHash, dev->contractHash)); auto encoded = mcpp::runtime::serialize_runtime_binding(*el8); auto decoded = mcpp::runtime::deserialize_runtime_binding(encoded); - ASSERT_TRUE(decoded.has_value()) << decoded.error(); - EXPECT_EQ(decoded->contractHash, el8->contractHash); + if (auto&& failed = expect(fatal(decoded.has_value())); !failed) failed << decoded.error(); + expect(eq(decoded->contractHash, el8->contractHash)); if constexpr (mcpplibs::os::is_linux) { - ASSERT_FALSE(decoded->hostLibc.empty()); - EXPECT_EQ(decoded->hostLibc, "2.43"); + expect(fatal(!(decoded->hostLibc.empty()))); + expect(eq(decoded->hostLibc, "2.43")); } else { - EXPECT_TRUE(decoded->hostLibc.empty()); + expect(decoded->hostLibc.empty()); } - EXPECT_EQ(decoded->subosDir, el8->subosDir); - ASSERT_EQ(decoded->environment.size(), 1u); - EXPECT_EQ(decoded->environment[0].var, "DRIVERS"); - ASSERT_EQ(decoded->runtimeProviders.size(), 1u); - EXPECT_EQ(decoded->runtimeProviders[0].provider.namespace_, "xim"); - ASSERT_EQ(decoded->runtimeArtifacts.size(), 1u); - EXPECT_EQ(decoded->runtimeArtifacts[0].hostFingerprint, "host-2"); - EXPECT_TRUE(decoded->declared); -} + expect(eq(decoded->subosDir, el8->subosDir)); + expect(fatal(eq(decoded->environment.size(), 1u))); + expect(eq(decoded->environment[0].var, "DRIVERS")); + expect(fatal(eq(decoded->runtimeProviders.size(), 1u))); + expect(eq(decoded->runtimeProviders[0].provider.namespace_, "xim")); + expect(fatal(eq(decoded->runtimeArtifacts.size(), 1u))); + expect(eq(decoded->runtimeArtifacts[0].hostFingerprint, "host-2")); + expect(bool(decoded->declared)); +}}; // ── absence degrades, contradiction fails (openxlings/xlings#543) ────────── // @@ -306,12 +305,12 @@ TEST(RuntimeBinding, NamedEnvironmentsHaveDistinctContractsAndRoundTrip) { // already paid for applies here too: data that is missing or newer must not // invalidate the program that reads it. -TEST(RuntimeBindingDegradation, UndeclaredSubosStillResolves) { +UTest undeclared_subos_still_resolves{"RuntimeBindingDegradation/UndeclaredSubosStillResolves", [] { RuntimeHome h; auto manifest = named("bare"); auto selection = runtime::select_runtime(manifest, std::nullopt, h.dir / "repo"); - ASSERT_TRUE(selection); + expect(fatal(bool(selection))); // Exists, describes nothing. The common state of a freshly created SubOS, // and the permanent state on a platform whose xlings does not write the // block at all. @@ -320,49 +319,48 @@ TEST(RuntimeBindingDegradation, UndeclaredSubosStillResolves) { auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); - ASSERT_TRUE(binding.has_value()) - << "absence must degrade, not fail: " << binding.error(); - EXPECT_FALSE(binding->declared); - EXPECT_FALSE(binding->note.empty()) + if (auto&& failed = expect(fatal(binding.has_value())); !failed) failed << "absence must degrade, not fail: " << binding.error(); + expect(!binding->declared); + expect(!(binding->note.empty())) << "a degradation nobody reports is indistinguishable from none"; - EXPECT_TRUE(binding->runtimeId.empty()); - EXPECT_FALSE(binding->hermetic()); -} + expect(binding->runtimeId.empty()); + expect(!binding->hermetic()); +}}; // The cache must survive it too. A degraded binding legitimately has schema 0 // and no runtime identity, and a completeness check that demands those fields // would make every cached degraded binding undecodable — sending the build // back down the slow path on every single invocation, forever. -TEST(RuntimeBindingDegradation, UndeclaredBindingRoundTrips) { +UTest undeclared_binding_round_trips{"RuntimeBindingDegradation/UndeclaredBindingRoundTrips", [] { RuntimeHome h; auto manifest = named("bare"); auto selection = runtime::select_runtime(manifest, std::nullopt, h.dir / "repo"); - ASSERT_TRUE(selection); + expect(fatal(bool(selection))); std::filesystem::create_directories( h.dir / "repo" / ".mcpp" / ".xlings" / "subos" / "bare"); auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); - ASSERT_TRUE(binding.has_value()); + expect(fatal(binding.has_value())); auto decoded = mcpp::runtime::deserialize_runtime_binding( mcpp::runtime::serialize_runtime_binding(*binding)); - ASSERT_TRUE(decoded.has_value()) << decoded.error(); - EXPECT_EQ(decoded->contractHash, binding->contractHash); - EXPECT_FALSE(decoded->declared); - EXPECT_EQ(decoded->note, binding->note); -} + if (auto&& failed = expect(fatal(decoded.has_value())); !failed) failed << decoded.error(); + expect(eq(decoded->contractHash, binding->contractHash)); + expect(!decoded->declared); + expect(eq(decoded->note, binding->note)); +}}; // A newer schema is read, not refused. `subos_info::read` already documents // this ("a HIGHER one on disk is still read"); the consumer used to demand // equality, so the day xlings writes schema 2 every build on every platform // would have stopped at once. -TEST(RuntimeBindingDegradation, NewerSchemaIsReadNotRefused) { +UTest newer_schema_is_read_not_refused{"RuntimeBindingDegradation/NewerSchemaIsReadNotRefused", [] { RuntimeHome h; auto manifest = named("future"); auto selection = runtime::select_runtime(manifest, std::nullopt, h.dir / "repo"); - ASSERT_TRUE(selection); + expect(fatal(bool(selection))); auto subos = h.dir / "repo" / ".mcpp" / ".xlings" / "subos" / "future"; std::filesystem::create_directories(subos); std::ofstream(subos / ".xlings.json") << R"({ @@ -376,29 +374,28 @@ TEST(RuntimeBindingDegradation, NewerSchemaIsReadNotRefused) { auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); - ASSERT_TRUE(binding.has_value()) - << "a newer contract must not invalidate an older mcpp: " + if (auto&& failed = expect(fatal(binding.has_value())); !failed) failed << "a newer contract must not invalidate an older mcpp: " << binding.error(); - EXPECT_TRUE(binding->declared); - EXPECT_EQ(binding->runtimeId, "glibc@2.39"); - EXPECT_NE(binding->note.find("newer"), std::string::npos) + expect(bool(binding->declared)); + expect(eq(binding->runtimeId, "glibc@2.39")); + expect(ne(binding->note.find("newer"), std::string::npos)) << "reading a newer schema silently is the other half of the defect"; -} +}}; // The boundary. A SubOS the user NAMED and that is not there cannot be // satisfied under any interpretation, and picking a different one would make // one mcpp.toml mean different ABIs on different machines. -TEST(RuntimeBindingDegradation, MissingSubosStillFails) { +UTest missing_subos_still_fails{"RuntimeBindingDegradation/MissingSubosStillFails", [] { RuntimeHome h; auto manifest = named("never-created"); auto selection = runtime::select_runtime(manifest, std::nullopt, h.dir / "repo"); - ASSERT_TRUE(selection); + expect(fatal(bool(selection))); auto binding = mcpp::runtime::resolve_runtime_binding( *selection, {}, h.cfg.xlingsHome()); - ASSERT_FALSE(binding.has_value()) + expect(fatal(!binding.has_value())) << "a contradiction must be reported, not degraded"; - EXPECT_NE(binding.error().find("does not exist"), std::string::npos); -} + expect(ne(binding.error().find("does not exist"), std::string::npos)); +}}; } // namespace diff --git a/tests/unit/test_scaffold.cpp b/tests/unit/test_scaffold.cpp index 9d346b472..8b55d6c1f 100644 --- a/tests/unit/test_scaffold.cpp +++ b/tests/unit/test_scaffold.cpp @@ -1,14 +1,16 @@ -#include import std; +import mcpplibs.mtest; import mcpp.scaffold; import mcpp.scaffold.project_name; import mcpp.pm.dependency_selector; import mcpplibs.os.fs; +using namespace mcpplibs::mtest; + namespace { -TEST(TemplateSpec, ParsesSharedExactPackageGrammar) { +UTest parses_shared_exact_package_grammar{"TemplateSpec/ParsesSharedExactPackageGrammar", [] { struct Row { std::string input; std::optional ns; @@ -32,32 +34,32 @@ TEST(TemplateSpec, ParsesSharedExactPackageGrammar) { for (auto const& row : rows) { auto parsed = mcpp::scaffold::parse_template_spec(row.input); - ASSERT_TRUE(parsed.has_value()) << row.input; - EXPECT_EQ(parsed->package.namespace_, row.ns) << row.input; - EXPECT_EQ(parsed->package.name, row.name) << row.input; - EXPECT_EQ(parsed->version, row.version) << row.input; - EXPECT_EQ(parsed->templateName, row.tmpl) << row.input; - EXPECT_FALSE(parsed->legacyList) << row.input; + expect(fatal(parsed.has_value())) << row.input; + expect(eq(parsed->package.namespace_, row.ns)) << row.input; + expect(eq(parsed->package.name, row.name)) << row.input; + expect(eq(parsed->version, row.version)) << row.input; + expect(eq(parsed->templateName, row.tmpl)) << row.input; + expect(!parsed->legacyList) << row.input; } -} +}}; -TEST(TemplateSpec, BarePackageNormalizesToMcpplibs) { +UTest bare_package_normalizes_to_mcpplibs{"TemplateSpec/BarePackageNormalizesToMcpplibs", [] { auto parsed = mcpp::scaffold::parse_template_spec("imgui"); - ASSERT_TRUE(parsed.has_value()); + expect(fatal(parsed.has_value())); auto coord = mcpp::pm::normalize_package_selector(parsed->package); - EXPECT_EQ(coord.namespace_, "mcpplibs"); - EXPECT_EQ(coord.shortName, "imgui"); -} + expect(eq(coord.namespace_, "mcpplibs")); + expect(eq(coord.shortName, "imgui")); +}}; -TEST(TemplateSpec, RecognizesTrailingColonOnlyAsLegacyListAlias) { +UTest recognizes_trailing_colon_only_as_legacy_list_alias{"TemplateSpec/RecognizesTrailingColonOnlyAsLegacyListAlias", [] { auto parsed = mcpp::scaffold::parse_template_spec("ocornut.imgui@1.92.8:"); - ASSERT_TRUE(parsed.has_value()); - EXPECT_TRUE(parsed->legacyList); - EXPECT_EQ(parsed->version, "1.92.8"); - EXPECT_FALSE(parsed->templateName.has_value()); -} + expect(fatal(parsed.has_value())); + expect(bool(parsed->legacyList)); + expect(eq(parsed->version, "1.92.8")); + expect(!(parsed->templateName.has_value())); +}}; -TEST(TemplateSpec, RejectsAmbiguousEmptyOrUnsafeComponents) { +UTest rejects_ambiguous_empty_or_unsafe_components{"TemplateSpec/RejectsAmbiguousEmptyOrUnsafeComponents", [] { const std::vector invalid{ "", "@1.0.0", ":starter", ".imgui@1.0", "ocornut..imgui", "ocornut.imgui@", "ocornut.imgui:bad:extra", @@ -68,90 +70,89 @@ TEST(TemplateSpec, RejectsAmbiguousEmptyOrUnsafeComponents) { }; for (auto const& input : invalid) { auto parsed = mcpp::scaffold::parse_template_spec(input); - EXPECT_FALSE(parsed.has_value()) << input; - if (!parsed) EXPECT_FALSE(parsed.error().message.empty()) << input; + expect(!parsed.has_value()) << input; + if (!parsed) expect(!(parsed.error().message.empty())) << input; } -} +}}; -TEST(TemplateSelection, SoleTemplateWithoutExplicitDefaultAutoWins) { +UTest sole_template_without_explicit_default_auto_wins{"TemplateSelection/SoleTemplateWithoutExplicitDefaultAutoWins", [] { std::vector entries{ {"solo", {.description = "only choice", .isDefault = false}}, }; auto chosen = mcpp::scaffold::select_template(entries, std::nullopt); - ASSERT_TRUE(chosen.has_value()); - EXPECT_EQ((*chosen)->name, "solo"); -} + expect(fatal(chosen.has_value())); + expect(eq((*chosen)->name, "solo")); +}}; -TEST(TemplateSelection, OneExplicitDefaultWinsAmongMany) { +UTest one_explicit_default_wins_among_many{"TemplateSelection/OneExplicitDefaultWinsAmongMany", [] { std::vector entries{ {"a", {.isDefault = false}}, {"b", {.isDefault = true}}, }; auto chosen = mcpp::scaffold::select_template(entries, std::nullopt); - ASSERT_TRUE(chosen.has_value()); - EXPECT_EQ((*chosen)->name, "b"); -} + expect(fatal(chosen.has_value())); + expect(eq((*chosen)->name, "b")); +}}; -TEST(TemplateSelection, MultipleWithoutDefaultAreAmbiguous) { +UTest multiple_without_default_are_ambiguous{"TemplateSelection/MultipleWithoutDefaultAreAmbiguous", [] { std::vector entries{ {"a", {.isDefault = false}}, {"b", {.isDefault = false}}, }; auto chosen = mcpp::scaffold::select_template(entries, std::nullopt); - ASSERT_FALSE(chosen.has_value()); - EXPECT_NE(chosen.error().find("a"), std::string::npos); - EXPECT_NE(chosen.error().find("b"), std::string::npos); -} + expect(fatal(!chosen.has_value())); + expect(ne(chosen.error().find("a"), std::string::npos)); + expect(ne(chosen.error().find("b"), std::string::npos)); +}}; -TEST(TemplateSelection, ExplicitNameWinsAndUnknownListsChoices) { +UTest explicit_name_wins_and_unknown_lists_choices{"TemplateSelection/ExplicitNameWinsAndUnknownListsChoices", [] { std::vector entries{ {"a", {.isDefault = false}}, {"b", {.isDefault = false}}, }; auto chosen = mcpp::scaffold::select_template(entries, std::string_view{"b"}); - ASSERT_TRUE(chosen.has_value()); - EXPECT_EQ((*chosen)->name, "b"); + expect(fatal(chosen.has_value())); + expect(eq((*chosen)->name, "b")); auto missing = mcpp::scaffold::select_template( entries, std::string_view{"missing"}); - ASSERT_FALSE(missing.has_value()); - EXPECT_NE(missing.error().find("a"), std::string::npos); - EXPECT_NE(missing.error().find("b"), std::string::npos); -} + expect(fatal(!missing.has_value())); + expect(ne(missing.error().find("a"), std::string::npos)); + expect(ne(missing.error().find("b"), std::string::npos)); +}}; -TEST(TemplateSelection, EmptyOrMultipleDefaultsAreProviderErrors) { +UTest empty_or_multiple_defaults_are_provider_errors{"TemplateSelection/EmptyOrMultipleDefaultsAreProviderErrors", [] { std::vector empty; auto noTemplates = mcpp::scaffold::select_template(empty, std::nullopt); - ASSERT_FALSE(noTemplates.has_value()); - EXPECT_NE(noTemplates.error().find("no templates"), std::string::npos); + expect(fatal(!noTemplates.has_value())); + expect(ne(noTemplates.error().find("no templates"), std::string::npos)); std::vector entries{ {"a", {.isDefault = true}}, {"b", {.isDefault = true}}, }; auto duplicate = mcpp::scaffold::select_template(entries, std::nullopt); - ASSERT_FALSE(duplicate.has_value()); - EXPECT_NE(duplicate.error().find("more than one default"), - std::string::npos); -} + expect(fatal(!duplicate.has_value())); + expect(ne(duplicate.error().find("more than one default"), std::string::npos)); +}}; -TEST(ProjectName, AcceptsPortableBareAndQualifiedNames) { +UTest accepts_portable_bare_and_qualified_names{"ProjectName/AcceptsPortableBareAndQualifiedNames", [] { auto bare = mcpp::scaffold::validate_project_name("hello-world"); - ASSERT_TRUE(bare.has_value()); - EXPECT_EQ(bare->directoryName, "hello-world"); - EXPECT_EQ(bare->name, "hello-world"); - EXPECT_TRUE(bare->namespace_.empty()); - EXPECT_EQ(bare->qualifiedName, "hello-world"); + expect(fatal(bare.has_value())); + expect(eq(bare->directoryName, "hello-world")); + expect(eq(bare->name, "hello-world")); + expect(bare->namespace_.empty()); + expect(eq(bare->qualifiedName, "hello-world")); auto qualified = mcpp::scaffold::validate_project_name("acme.tools.app"); - ASSERT_TRUE(qualified.has_value()); - EXPECT_EQ(qualified->directoryName, "acme.tools.app"); - EXPECT_EQ(qualified->namespace_, "acme.tools"); - EXPECT_EQ(qualified->name, "app"); - EXPECT_EQ(qualified->qualifiedName, "acme.tools.app"); -} - -TEST(ProjectName, RejectsPathEscapeControlsReservedNamesAndLegacyMarker) { + expect(fatal(qualified.has_value())); + expect(eq(qualified->directoryName, "acme.tools.app")); + expect(eq(qualified->namespace_, "acme.tools")); + expect(eq(qualified->name, "app")); + expect(eq(qualified->qualifiedName, "acme.tools.app")); +}}; + +UTest rejects_path_escape_controls_reserved_names_and_legacy_marker{"ProjectName/RejectsPathEscapeControlsReservedNamesAndLegacyMarker", [] { const std::vector invalid{ "", ".", "..", "/absolute", "a/b", "a\\b", "a:b", "a\"b", "a?b", "a*b", "a|b", "ab", "trail.", "trail ", @@ -161,12 +162,12 @@ TEST(ProjectName, RejectsPathEscapeControlsReservedNamesAndLegacyMarker) { }; for (auto const& name : invalid) { auto result = mcpp::scaffold::validate_project_name(name); - EXPECT_FALSE(result.has_value()) << name; - if (!result) EXPECT_FALSE(result.error().message.empty()) << name; + expect(!result.has_value()) << name; + if (!result) expect(!(result.error().message.empty())) << name; } -} +}}; -TEST(RenderTokens, ExpandsCompleteVocabularyWithoutRescanningValues) { +UTest expands_complete_vocabulary_without_rescanning_values{"RenderTokens/ExpandsCompleteVocabularyWithoutRescanningValues", [] { mcpp::scaffold::RenderVars vars{ .projectName = "app-{{template.name}}", .projectNamespace = "acme", @@ -183,25 +184,23 @@ TEST(RenderTokens, ExpandsCompleteVocabularyWithoutRescanningValues) { "{{template.package.selector}}|{{template.package.version}}|" "{{template.name}}|{{self.name}}|{{self.version}}", vars); - ASSERT_TRUE(rendered.has_value()) << rendered.error().message; - EXPECT_EQ(*rendered, - "app-{{template.name}}|acme|acme.app|widgets.ui|starter|" - "widgets.ui.starter|2.0.0|window|widgets.ui.starter|2.0.0"); -} + if (auto&& failed = expect(fatal(rendered.has_value())); !failed) failed << rendered.error().message; + expect(eq(*rendered, "app-{{template.name}}|acme|acme.app|widgets.ui|starter|" + "widgets.ui.starter|2.0.0|window|widgets.ui.starter|2.0.0")); +}}; -TEST(RenderTokens, RejectsUnknownOrUnterminatedTokens) { +UTest rejects_unknown_or_unterminated_tokens{"RenderTokens/RejectsUnknownOrUnterminatedTokens", [] { mcpp::scaffold::RenderVars vars; auto unknown = mcpp::scaffold::render_tokens("{{project.typo}}", vars); - ASSERT_FALSE(unknown.has_value()); - EXPECT_NE(unknown.error().message.find("project.typo"), std::string::npos); + expect(fatal(!unknown.has_value())); + expect(ne(unknown.error().message.find("project.typo"), std::string::npos)); auto unterminated = mcpp::scaffold::render_tokens("{{project.name", vars); - ASSERT_FALSE(unterminated.has_value()); - EXPECT_NE(unterminated.error().message.find("unterminated"), - std::string::npos); -} + expect(fatal(!unterminated.has_value())); + expect(ne(unterminated.error().message.find("unterminated"), std::string::npos)); +}}; -TEST(ScaffoldTransaction, RollbackRemovesSiblingStagingTree) { +UTest rollback_removes_sibling_staging_tree{"ScaffoldTransaction/RollbackRemovesSiblingStagingTree", [] { auto parent = std::filesystem::temp_directory_path() / std::format("mcpp-scaffold-rollback-{}", std::chrono::steady_clock::now().time_since_epoch().count()); @@ -209,47 +208,47 @@ TEST(ScaffoldTransaction, RollbackRemovesSiblingStagingTree) { std::filesystem::path stage; { auto tx = mcpp::scaffold::ScaffoldTransaction::begin(parent, "app"); - ASSERT_TRUE(tx.has_value()) << tx.error(); + if (auto&& failed = expect(fatal(tx.has_value())); !failed) failed << tx.error(); stage = tx->staging_path(); std::ofstream(stage / "partial.txt") << "partial"; - EXPECT_TRUE(std::filesystem::exists(stage)); + expect(std::filesystem::exists(stage)); } - EXPECT_FALSE(std::filesystem::exists(stage)); - EXPECT_FALSE(std::filesystem::exists(parent / "app")); + expect(!std::filesystem::exists(stage)); + expect(!std::filesystem::exists(parent / "app")); std::filesystem::remove_all(parent); -} +}}; -TEST(ScaffoldTransaction, CommitIsAtomicAndRenameFailureRollsBack) { +UTest commit_is_atomic_and_rename_failure_rolls_back{"ScaffoldTransaction/CommitIsAtomicAndRenameFailureRollsBack", [] { auto parent = std::filesystem::temp_directory_path() / std::format("mcpp-scaffold-commit-{}", std::chrono::steady_clock::now().time_since_epoch().count()); std::filesystem::create_directories(parent); { auto tx = mcpp::scaffold::ScaffoldTransaction::begin(parent, "ok"); - ASSERT_TRUE(tx.has_value()) << tx.error(); + if (auto&& failed = expect(fatal(tx.has_value())); !failed) failed << tx.error(); std::ofstream(tx->staging_path() / "done.txt") << "done"; auto committed = tx->commit(); - ASSERT_TRUE(committed.has_value()) << committed.error(); - EXPECT_TRUE(std::filesystem::exists(parent / "ok" / "done.txt")); - EXPECT_FALSE(std::filesystem::exists(tx->staging_path())); + if (auto&& failed = expect(fatal(committed.has_value())); !failed) failed << committed.error(); + expect(std::filesystem::exists(parent / "ok" / "done.txt")); + expect(!(std::filesystem::exists(tx->staging_path()))); } { auto tx = mcpp::scaffold::ScaffoldTransaction::begin(parent, "collision"); - ASSERT_TRUE(tx.has_value()) << tx.error(); + if (auto&& failed = expect(fatal(tx.has_value())); !failed) failed << tx.error(); auto stage = tx->staging_path(); std::ofstream(stage / "partial.txt") << "partial"; std::filesystem::create_directories(parent / "collision"); auto committed = tx->commit(); - ASSERT_FALSE(committed.has_value()); - EXPECT_TRUE(std::filesystem::exists(stage)); + expect(fatal(!committed.has_value())); + expect(std::filesystem::exists(stage)); } for (auto const& entry : std::filesystem::directory_iterator(parent)) { - EXPECT_FALSE(entry.path().filename().string().starts_with(".mcpp-new-")); + expect(!(entry.path().filename().string().starts_with(".mcpp-new-"))); } std::filesystem::remove_all(parent); -} +}}; -TEST(ScaffoldFilesystem, AtomicRenameNeverReplacesExistingTarget) { +UTest atomic_rename_never_replaces_existing_target{"ScaffoldFilesystem/AtomicRenameNeverReplacesExistingTarget", [] { auto parent = std::filesystem::temp_directory_path() / std::format("mcpp-scaffold-rename-{}", std::chrono::steady_clock::now().time_since_epoch().count()); @@ -262,21 +261,21 @@ TEST(ScaffoldFilesystem, AtomicRenameNeverReplacesExistingTarget) { auto collision = mcpplibs::os::fs::place( source, target); - ASSERT_FALSE(collision.has_value()); - EXPECT_TRUE(std::filesystem::exists(source / "payload.txt")); - EXPECT_TRUE(std::filesystem::exists(target / "owner.txt")); + expect(fatal(!collision.has_value())); + expect(std::filesystem::exists(source / "payload.txt")); + expect(std::filesystem::exists(target / "owner.txt")); std::filesystem::remove_all(target); auto committed = mcpplibs::os::fs::place( source, target); - ASSERT_TRUE(committed.has_value()) << committed.error().message(); - EXPECT_FALSE(std::filesystem::exists(source)); - EXPECT_TRUE(std::filesystem::exists(target / "payload.txt")); + if (auto&& failed = expect(fatal(committed.has_value())); !failed) failed << committed.error().message(); + expect(!std::filesystem::exists(source)); + expect(std::filesystem::exists(target / "payload.txt")); std::filesystem::remove_all(parent); -} +}}; #if !defined(_WIN32) -TEST(ScaffoldTransaction, ReadWriteAndCopyFailuresRollbackCompletely) { +UTest read_write_and_copy_failures_rollback_completely{"ScaffoldTransaction/ReadWriteAndCopyFailuresRollbackCompletely", [] { namespace fs = std::filesystem; auto parent = fs::temp_directory_path() / std::format("mcpp-scaffold-iofail-{}", @@ -295,15 +294,15 @@ TEST(ScaffoldTransaction, ReadWriteAndCopyFailuresRollbackCompletely) { fs::path readStage; { auto tx = mcpp::scaffold::ScaffoldTransaction::begin(parent, "read-app"); - ASSERT_TRUE(tx.has_value()) << tx.error(); + if (auto&& failed = expect(fatal(tx.has_value())); !failed) failed << tx.error(); readStage = tx->staging_path(); auto result = mcpp::scaffold::instantiate( readProvider, readStage, vars); - EXPECT_FALSE(result.has_value()); + expect(!result.has_value()); fs::permissions(unreadable, fs::perms::owner_read); } - EXPECT_FALSE(fs::exists(readStage)); - EXPECT_FALSE(fs::exists(parent / "read-app")); + expect(!fs::exists(readStage)); + expect(!fs::exists(parent / "read-app")); auto exercise_locked_destination = [&](std::string_view caseName, std::string_view sourceName) { @@ -314,31 +313,31 @@ TEST(ScaffoldTransaction, ReadWriteAndCopyFailuresRollbackCompletely) { { auto tx = mcpp::scaffold::ScaffoldTransaction::begin( parent, std::format("{}-app", caseName)); - EXPECT_TRUE(tx.has_value()); + expect(tx.has_value()); if (!tx) return; stage = tx->staging_path(); fs::create_directories(stage / "locked"); fs::permissions(stage / "locked", fs::perms::owner_read | fs::perms::owner_exec); auto result = mcpp::scaffold::instantiate(provider, stage, vars); - EXPECT_FALSE(result.has_value()) << caseName; + expect(!result.has_value()) << caseName; fs::permissions(stage / "locked", fs::perms::owner_all); } - EXPECT_FALSE(fs::exists(stage)) << caseName; - EXPECT_FALSE(fs::exists(parent / std::format("{}-app", caseName))) + expect(!fs::exists(stage)) << caseName; + expect(!(fs::exists(parent / std::format("{}-app", caseName)))) << caseName; }; exercise_locked_destination("write", "mcpp.toml.in"); exercise_locked_destination("copy", "data.bin"); for (auto const& entry : fs::directory_iterator(parent)) { - EXPECT_FALSE(entry.path().filename().string().starts_with(".mcpp-new-")); + expect(!(entry.path().filename().string().starts_with(".mcpp-new-"))); } fs::remove_all(parent); -} +}}; #endif -TEST(SelfDependencyInjection, ComparesCanonicalPackageIdentity) { +UTest compares_canonical_package_identity{"SelfDependencyInjection/ComparesCanonicalPackageIdentity", [] { auto root = std::filesystem::temp_directory_path() / std::format("mcpp-inject-{}", std::chrono::steady_clock::now().time_since_epoch().count()); @@ -362,7 +361,7 @@ widget = "1.0.0" }; auto injected = mcpp::scaffold::inject_self_dependency( manifest, vars, {"gui"}); - ASSERT_TRUE(injected.has_value()) << injected.error(); + if (auto&& failed = expect(fatal(injected.has_value())); !failed) failed << injected.error(); std::string text; { std::ifstream is(manifest); @@ -370,22 +369,20 @@ widget = "1.0.0" ss << is.rdbuf(); text = ss.str(); } - EXPECT_NE(text.find("[dependencies.acme]\nwidget = { version = \"2.0.0\", features = [\"gui\"] }"), - std::string::npos); - EXPECT_NE(text.find("[dependencies.compat]\nwidget = \"1.0.0\""), - std::string::npos); + expect(ne(text.find("[dependencies.acme]\nwidget = { version = \"2.0.0\", features = [\"gui\"] }"), std::string::npos)); + expect(ne(text.find("[dependencies.compat]\nwidget = \"1.0.0\""), std::string::npos)); auto again = mcpp::scaffold::inject_self_dependency( manifest, vars, {"gui"}); - ASSERT_TRUE(again.has_value()) << again.error(); + if (auto&& failed = expect(fatal(again.has_value())); !failed) failed << again.error(); { std::ifstream is(manifest); std::stringstream ss; ss << is.rdbuf(); - EXPECT_EQ(ss.str(), text); + expect(eq(ss.str(), text)); } std::filesystem::remove_all(root); -} +}}; } // namespace @@ -400,7 +397,7 @@ widget = "1.0.0" // includes . Measured: the injected dependency failed to compile // before the generated project was reached, in a project whose manifest // declares no dependencies at all. -TEST(ScaffoldTemplate, InjectSelfCanBeDeclined) { +UTest inject_self_can_be_declined{"ScaffoldTemplate/InjectSelfCanBeDeclined", [] { auto dir = std::filesystem::temp_directory_path() / "mcpp_tmpl_noself"; std::filesystem::remove_all(dir); std::filesystem::create_directories(dir); @@ -409,15 +406,15 @@ TEST(ScaffoldTemplate, InjectSelfCanBeDeclined) { f << "[template]\ndescription = \"d\"\n[template.inject]\nself = false\n"; } auto meta = mcpp::scaffold::load_meta(dir); - ASSERT_TRUE(meta.has_value()) << (meta ? "" : meta.error()); - EXPECT_FALSE(meta->injectSelf); + if (auto&& failed = expect(fatal(meta.has_value())); !failed) failed << (meta ? "" : meta.error()); + expect(!meta->injectSelf); std::filesystem::remove_all(dir); -} +}}; // The default must stay ON: every template that does not say otherwise relies // on the injection, and a silent flip would leave them all without their own // package. -TEST(ScaffoldTemplate, InjectSelfDefaultsToOn) { +UTest inject_self_defaults_to_on{"ScaffoldTemplate/InjectSelfDefaultsToOn", [] { auto dir = std::filesystem::temp_directory_path() / "mcpp_tmpl_self_default"; std::filesystem::remove_all(dir); std::filesystem::create_directories(dir); @@ -426,13 +423,13 @@ TEST(ScaffoldTemplate, InjectSelfDefaultsToOn) { f << "[template]\ndescription = \"d\"\n"; } auto meta = mcpp::scaffold::load_meta(dir); - ASSERT_TRUE(meta.has_value()) << (meta ? "" : meta.error()); - EXPECT_TRUE(meta->injectSelf); + if (auto&& failed = expect(fatal(meta.has_value())); !failed) failed << (meta ? "" : meta.error()); + expect(bool(meta->injectSelf)); std::filesystem::remove_all(dir); -} +}}; // The table form still carries features, and must not be read as "declined". -TEST(ScaffoldTemplate, InjectSelfTableFormStillInjects) { +UTest inject_self_table_form_still_injects{"ScaffoldTemplate/InjectSelfTableFormStillInjects", [] { auto dir = std::filesystem::temp_directory_path() / "mcpp_tmpl_self_table"; std::filesystem::remove_all(dir); std::filesystem::create_directories(dir); @@ -442,9 +439,9 @@ TEST(ScaffoldTemplate, InjectSelfTableFormStillInjects) { "[template.inject]\nself = { features = [\"x\"] }\n"; } auto meta = mcpp::scaffold::load_meta(dir); - ASSERT_TRUE(meta.has_value()) << (meta ? "" : meta.error()); - EXPECT_TRUE(meta->injectSelf); - ASSERT_EQ(meta->injectSelfFeatures.size(), 1u); - EXPECT_EQ(meta->injectSelfFeatures[0], "x"); + if (auto&& failed = expect(fatal(meta.has_value())); !failed) failed << (meta ? "" : meta.error()); + expect(bool(meta->injectSelf)); + expect(fatal(eq(meta->injectSelfFeatures.size(), 1u))); + expect(eq(meta->injectSelfFeatures[0], "x")); std::filesystem::remove_all(dir); -} +}}; diff --git a/tests/unit/test_schedule_policy.cpp b/tests/unit/test_schedule_policy.cpp index 375d44d6d..4053f6dac 100644 --- a/tests/unit/test_schedule_policy.cpp +++ b/tests/unit/test_schedule_policy.cpp @@ -4,15 +4,17 @@ // and no compiler — the table can be wrong in a way that only shows up as a // slower build, which is the kind of wrong that never gets noticed. -#include #include import std; +import mcpplibs.mtest; import mcpp.invocation; import mcpp.build.schedule.policy; import mcpp.toolchain.model; import mcpp.manifest; +using namespace mcpplibs::mtest; + using mcpp::build::schedule::Strategy; using mcpp::build::schedule::decide; using mcpp::build::schedule::decide_shape; @@ -48,74 +50,74 @@ mcpp::manifest::Manifest with_schedule(std::string v) { // backwards is silent: clang writes its BMI to the final path with O_TRUNC, so // detach-codegen would hand importers a half-written file; gcc has no cheap // BMI-only mode, so two-phase would just compile everything twice. -TEST(SchedulePolicy, EachCompilerGetsItsOwnMechanism) { - EXPECT_EQ(decide(with(CompilerId::Clang), "on", 8).strategy, Strategy::TwoPhase); - EXPECT_EQ(decide(with(CompilerId::GCC), "on", 8).strategy, Strategy::DetachCodegen); -} +UTest each_compiler_gets_its_own_mechanism{"SchedulePolicy/EachCompilerGetsItsOwnMechanism", [] { + expect(eq(decide(with(CompilerId::Clang), "on", 8).strategy, Strategy::TwoPhase)); + expect(eq(decide(with(CompilerId::GCC), "on", 8).strategy, Strategy::DetachCodegen)); +}}; // Unmeasured means None. A guess here is not a slow build, it is a miscompile: // a BMI read while it is still being written is not a diagnostic. // `auto` is OFF for now — pinned, because it is a decision rather than a gap. -TEST(SchedulePolicy, AutoIsOptInForNow) { - EXPECT_EQ(decide(with(CompilerId::GCC), "auto", 8).strategy, Strategy::None); - EXPECT_NE(decide(with(CompilerId::GCC), "on", 8).strategy, Strategy::None); -} +UTest auto_is_opt_in_for_now{"SchedulePolicy/AutoIsOptInForNow", [] { + expect(eq(decide(with(CompilerId::GCC), "auto", 8).strategy, Strategy::None)); + expect(ne(decide(with(CompilerId::GCC), "on", 8).strategy, Strategy::None)); +}}; -TEST(SchedulePolicy, UnmeasuredCompilersStayConservative) { - EXPECT_EQ(decide(with(CompilerId::MSVC), "on", 8).strategy, Strategy::None); - EXPECT_EQ(decide(with(CompilerId::Unknown), "on", 8).strategy, Strategy::None); -} +UTest unmeasured_compilers_stay_conservative{"SchedulePolicy/UnmeasuredCompilersStayConservative", [] { + expect(eq(decide(with(CompilerId::MSVC), "on", 8).strategy, Strategy::None)); + expect(eq(decide(with(CompilerId::Unknown), "on", 8).strategy, Strategy::None)); +}}; // Asserted from BOTH sides: that "off" disables, and that the same input with // "auto" does NOT. Checking only the first would pass an implementation that // never enables anything at all. -TEST(SchedulePolicy, OffDisablesAndAutoDoesNot) { - EXPECT_EQ(decide(with(CompilerId::GCC), "off", 8).strategy, Strategy::None); - EXPECT_NE(decide(with(CompilerId::GCC), "on", 8).strategy, Strategy::None); -} +UTest off_disables_and_auto_does_not{"SchedulePolicy/OffDisablesAndAutoDoesNot", [] { + expect(eq(decide(with(CompilerId::GCC), "off", 8).strategy, Strategy::None)); + expect(ne(decide(with(CompilerId::GCC), "on", 8).strategy, Strategy::None)); +}}; // HAZARD 2, encoded. Under detach-codegen a compiler stops holding a ninja slot // the moment it publishes its BMI, so ninja's -j is no longer a bound on how // many compilers run. With the two equal, ninja's slots fill with edges that // are merely sleeping, the ready frontier starves, and the schedule degenerates // to the baseline — which is exactly what the first prototype measured. -TEST(SchedulePolicy, DetachCodegenGivesNinjaMoreSlotsThanCompilers) { +UTest detach_codegen_gives_ninja_more_slots_than_compilers{"SchedulePolicy/DetachCodegenGivesNinjaMoreSlotsThanCompilers", [] { const auto d = decide(with(CompilerId::GCC), "on", 32); - EXPECT_EQ(d.compilerCap, 32); - EXPECT_GT(d.ninjaJobs, d.compilerCap); -} + expect(eq(d.compilerCap, 32)); + expect(gt(d.ninjaJobs, d.compilerCap)); +}}; // Two-phase runs ordinary compilers that hold their slot for the whole compile, // so inflating -j there would only oversubscribe the machine. -TEST(SchedulePolicy, TwoPhaseLeavesTheJobCountAlone) { +UTest two_phase_leaves_the_job_count_alone{"SchedulePolicy/TwoPhaseLeavesTheJobCountAlone", [] { const auto d = decide(with(CompilerId::Clang), "on", 32); - EXPECT_EQ(d.ninjaJobs, d.compilerCap); -} + expect(eq(d.ninjaJobs, d.compilerCap)); +}}; // A scheduler that silently declines to optimise cannot be debugged: "why is my // build not using the fast shape?" has to have an answer that ships with the // build. Every branch, including the ones that choose None. -TEST(SchedulePolicy, EveryDecisionCarriesAReason) { +UTest every_decision_carries_a_reason{"SchedulePolicy/EveryDecisionCarriesAReason", [] { for (auto id : {CompilerId::GCC, CompilerId::Clang, CompilerId::MSVC, CompilerId::Unknown}) { - EXPECT_FALSE(decide(with(id), "auto", 8).reason.empty()) + expect(!(decide(with(id), "auto", 8).reason.empty())) << "no reason for compiler id " << static_cast(id); - EXPECT_FALSE(decide(with(id), "off", 8).reason.empty()) + expect(!(decide(with(id), "off", 8).reason.empty())) << "no reason when disabled, compiler id " << static_cast(id); } -} +}}; // A host that reports nothing must not turn into "-j0" or a negative cap. -TEST(SchedulePolicy, ZeroJobsStaysZeroRatherThanBecomingNonsense) { +UTest zero_jobs_stays_zero_rather_than_becoming_nonsense{"SchedulePolicy/ZeroJobsStaysZeroRatherThanBecomingNonsense", [] { const auto d = decide(with(CompilerId::GCC), "on", 0); - EXPECT_EQ(d.compilerCap, 0); - EXPECT_EQ(d.ninjaJobs, 0); + expect(eq(d.compilerCap, 0)); + expect(eq(d.ninjaJobs, 0)); // ...and it must SAY so. Under detach-codegen a cap of 0 disables the // semaphore, which is the only bound on how many compilers run at once, so // this state is not a neutral default — it is unbounded concurrency. - EXPECT_NE(d.reason.find("bounded by nothing"), std::string::npos) + expect(ne(d.reason.find("bounded by nothing"), std::string::npos)) << "a zero cap silently means no bound at all: " << d.reason; -} +}}; // THE DEFAULT CONFIGURATION MUST STILL BE BOUNDED. // @@ -130,21 +132,21 @@ TEST(SchedulePolicy, ZeroJobsStaysZeroRatherThanBecomingNonsense) { // `sched_cap = 0`, i.e. ninja starting compiles as fast as BMIs appeared with // nothing limiting them, on a workload whose single compile peaks near a // gigabyte. The caller now resolves what the host would pick and hands it in. -TEST(SchedulePolicy, DetachCodegenFallsBackToTheHostWhenNoJobCountWasGiven) { +UTest detach_codegen_falls_back_to_the_host_when_no_job_count_was_given{"SchedulePolicy/DetachCodegenFallsBackToTheHostWhenNoJobCountWasGiven", [] { const auto d = decide(with(CompilerId::GCC), "on", /*hostJobs=*/0, /*autoJobs=*/24); - EXPECT_EQ(d.strategy, Strategy::DetachCodegen); - EXPECT_EQ(d.compilerCap, 24) << "the semaphore would be disabled"; - EXPECT_GT(d.ninjaJobs, d.compilerCap) << "hazard 2: -j must exceed the cap"; + expect(eq(d.strategy, Strategy::DetachCodegen)); + expect(eq(d.compilerCap, 24)) << "the semaphore would be disabled"; + expect(gt(d.ninjaJobs, d.compilerCap)) << "hazard 2: -j must exceed the cap"; // An explicit job count still wins over the fallback. const auto e = decide(with(CompilerId::GCC), "on", /*hostJobs=*/8, /*autoJobs=*/24); - EXPECT_EQ(e.compilerCap, 8); + expect(eq(e.compilerCap, 8)); // The fallback is only for the strategy that needs it; two-phase uses // ordinary edges, where ninja's -j is already the bound. const auto c = decide(with(CompilerId::Clang), "on", /*hostJobs=*/0, /*autoJobs=*/24); - EXPECT_EQ(c.ninjaJobs, 0) << "clang must still defer to the backend default"; -} + expect(eq(c.ninjaJobs, 0)) << "clang must still defer to the backend default"; +}}; // `auto` is bounded by recommended_jobs' ceiling of 64, but `--jobs N` is only // checked for `> 0`, so an absurd N reaches decide() intact and `cap * 6` was @@ -152,11 +154,11 @@ TEST(SchedulePolicy, DetachCodegenFallsBackToTheHostWhenNoJobCountWasGiven) { // one of the friendlier outcomes. Asserted as "still positive and still greater // than the cap" rather than against the clamp constant, so tuning the clamp does // not require editing the test that exists to stop it going negative. -TEST(SchedulePolicy, AnAbsurdJobCountDoesNotOverflowIntoANegativeOne) { +UTest an_absurd_job_count_does_not_overflow_into_a_negative_one{"SchedulePolicy/AnAbsurdJobCountDoesNotOverflowIntoANegativeOne", [] { const auto d = decide(with(CompilerId::GCC), "on", 2000000000); - EXPECT_GT(d.ninjaJobs, 0) << "ninja -j went non-positive"; - EXPECT_GT(d.ninjaJobs, 1) << "hazard 2: ninja must still outnumber the compilers"; -} + expect(gt(d.ninjaJobs, 0)) << "ninja -j went non-positive"; + expect(gt(d.ninjaJobs, 1)) << "hazard 2: ninja must still outnumber the compilers"; +}}; // ─── resolve_jobs precedence (#564) ──────────────────────────────────────── // @@ -173,7 +175,7 @@ TEST(SchedulePolicy, AnAbsurdJobCountDoesNotOverflowIntoANegativeOne) { // opposite of correct, because the global value is a property of the machine // and the environment variable is this invocation. Each of the three levels is // therefore checked against the level that must beat it. -TEST(SchedulePolicy, ResolveJobsPutsTheMachineBelowTheProjectAndTheInvocation) { +UTest resolve_jobs_puts_the_machine_below_the_project_and_the_invocation{"SchedulePolicy/ResolveJobsPutsTheMachineBelowTheProjectAndTheInvocation", [] { mcpp::manifest::Manifest bare; // no [build] jobs auto with_jobs = [](std::string v) { mcpp::manifest::Manifest m; @@ -185,24 +187,24 @@ TEST(SchedulePolicy, ResolveJobsPutsTheMachineBelowTheProjectAndTheInvocation) { { // The machine's value is used when nothing else says anything, and 0 // still means "say nothing" for everyone who has not written the key. ScopedVar clear("MCPP_JOBS", nullptr); - EXPECT_EQ(resolve_jobs(bare, {}, 4), 4); - EXPECT_EQ(resolve_jobs(bare, {}, 0), 0); + expect(eq(resolve_jobs(bare, {}, 4), 4)); + expect(eq(resolve_jobs(bare, {}, 0), 0)); // A non-positive value reads as absent rather than as a bound: that is // what the generated template's `0` means, and a negative -j would be // handed straight to the backend. - EXPECT_EQ(resolve_jobs(bare, {}, -1), 0); + expect(eq(resolve_jobs(bare, {}, -1), 0)); // The project beats the machine. A project that states a number has a // reason the machine cannot know. - EXPECT_EQ(resolve_jobs(with_jobs("6"), {}, 4), 6); + expect(eq(resolve_jobs(with_jobs("6"), {}, 4), 6)); } { // The invocation beats both. Without this leg the parameter could be // wired in above MCPP_JOBS and every other assertion here would pass. ScopedVar jobs("MCPP_JOBS", "2"); - EXPECT_EQ(resolve_jobs(bare, {}, 4), 2); - EXPECT_EQ(resolve_jobs(with_jobs("6"), {}, 4), 2); + expect(eq(resolve_jobs(bare, {}, 4), 2)); + expect(eq(resolve_jobs(with_jobs("6"), {}, 4), 2)); } -} +}}; // ─── requested_switch: a typo is a diagnostic, never a silent "auto" ─────── // @@ -210,63 +212,63 @@ TEST(SchedulePolicy, ResolveJobsPutsTheMachineBelowTheProjectAndTheInvocation) { // `bmi_schedule = "ON"` was accepted, meant OFF, and explained itself with // "the split schedule is opt-in until verified" — which reads as "you did not // ask for it" to someone who just did. -TEST(SchedulePolicy, RequestedSwitchPassesTheThreeSpellingsThrough) { +UTest requested_switch_passes_the_three_spellings_through{"SchedulePolicy/RequestedSwitchPassesTheThreeSpellingsThrough", [] { ScopedVar clear("MCPP_BMI_SCHEDULE", nullptr); - EXPECT_EQ(requested_switch(with_schedule("on")), "on"); - EXPECT_EQ(requested_switch(with_schedule("off")), "off"); - EXPECT_EQ(requested_switch(with_schedule("auto")), "auto"); - EXPECT_EQ(requested_switch(with_schedule("")), "auto"); // unset -} + expect(eq(requested_switch(with_schedule("on")), "on")); + expect(eq(requested_switch(with_schedule("off")), "off")); + expect(eq(requested_switch(with_schedule("auto")), "auto")); + expect(eq(requested_switch(with_schedule("")), "auto")); // unset +}}; -TEST(SchedulePolicy, RequestedSwitchReportsATypoInsteadOfSwallowingIt) { +UTest requested_switch_reports_a_typo_instead_of_swallowing_it{"SchedulePolicy/RequestedSwitchReportsATypoInsteadOfSwallowingIt", [] { ScopedVar clear("MCPP_BMI_SCHEDULE", nullptr); for (const char* typo : {"ON", "On", "true", "yes", "1", "enabled"}) { std::string seen; const auto v = requested_switch(with_schedule(typo), [&](std::string_view bad) { seen = bad; }); - EXPECT_EQ(v, "auto") << typo << " must fall back to the default"; - EXPECT_EQ(seen, typo) << typo << " was accepted silently"; + expect(eq(v, "auto")) << typo << " must fall back to the default"; + expect(eq(seen, typo)) << typo << " was accepted silently"; } -} +}}; // Both directions. Checking only that a typo warns would pass an implementation // that warns about everything, including the spellings that are correct. -TEST(SchedulePolicy, RequestedSwitchStaysQuietForValidValues) { +UTest requested_switch_stays_quiet_for_valid_values{"SchedulePolicy/RequestedSwitchStaysQuietForValidValues", [] { ScopedVar clear("MCPP_BMI_SCHEDULE", nullptr); for (const char* ok : {"on", "off", "auto"}) { bool warned = false; requested_switch(with_schedule(ok), [&](std::string_view) { warned = true; }); - EXPECT_FALSE(warned) << ok << " is valid but was reported as invalid"; + expect(!warned) << ok << " is valid but was reported as invalid"; } -} +}}; // The environment outranks the manifest — and is validated on the same terms. // A typo'd MCPP_BMI_SCHEDULE must not fall through to the manifest either: // silently honouring `[build] bmi_schedule = "on"` when the environment asked // for something unparseable would make the warning a lie. -TEST(SchedulePolicy, EnvironmentBeatsManifestAndIsValidatedToo) { +UTest environment_beats_manifest_and_is_validated_too{"SchedulePolicy/EnvironmentBeatsManifestAndIsValidatedToo", [] { { ScopedVar on("MCPP_BMI_SCHEDULE", "off"); - EXPECT_EQ(requested_switch(with_schedule("on")), "off"); + expect(eq(requested_switch(with_schedule("on")), "off")); } { ScopedVar bad("MCPP_BMI_SCHEDULE", "ON"); std::string seen; - EXPECT_EQ(requested_switch(with_schedule("on"), - [&](std::string_view b) { seen = b; }), "auto"); - EXPECT_EQ(seen, "ON"); + expect(eq(requested_switch(with_schedule("on"), + [&](std::string_view b) { seen = b; }), "auto")); + expect(eq(seen, "ON")); } -} +}}; // EVERY STRATEGY CAPS ITS COMPILERS (2026.11.7.1): the capped executor is what // registers a compiler in its build's generation, so a strategy without a split // still has a cap -- the job count, else what this machine would choose. -TEST(SchedulePolicy, EveryStrategyCarriesACompilerCap) { +UTest every_strategy_carries_a_compiler_cap{"SchedulePolicy/EveryStrategyCarriesACompilerCap", [] { for (auto id : {CompilerId::GCC, CompilerId::Clang, CompilerId::MSVC, CompilerId::Unknown}) { - EXPECT_EQ(decide(with(id), "auto", 8).compilerCap, 8) << static_cast(id); - EXPECT_EQ(decide(with(id), "off", 0, 6).compilerCap, 6) << static_cast(id); + expect(eq(decide(with(id), "auto", 8).compilerCap, 8)) << static_cast(id); + expect(eq(decide(with(id), "off", 0, 6).compilerCap, 6)) << static_cast(id); } // The shape alone is unchanged: only the split strategies carry a cap there. - EXPECT_EQ(decide_shape(with(CompilerId::MSVC), "auto", 8).compilerCap, 0); -} + expect(eq(decide_shape(with(CompilerId::MSVC), "auto", 8).compilerCap, 0)); +}}; diff --git a/tests/unit/test_search_versions.cpp b/tests/unit/test_search_versions.cpp index 9852e5aa8..236bec4cf 100644 --- a/tests/unit/test_search_versions.cpp +++ b/tests/unit/test_search_versions.cpp @@ -3,12 +3,14 @@ // semver-descending version union; anything unreadable degrades to empty // without failing the search. -#include import std; +import mcpplibs.mtest; import mcpp.config; import mcpp.pm.package_fetcher; +using namespace mcpplibs::mtest; + namespace { struct TempDir { @@ -29,7 +31,7 @@ mcpp::pm::Fetcher::SearchHit hit(std::string name) { return {"mcpplibs", std::move(name), "description"}; } -TEST(SearchVersions, MergesPerOsTablesOfTheHitsDescriptor) { +UTest merges_per_os_tables_of_the_hits_descriptor{"SearchVersions/MergesPerOsTablesOfTheHitsDescriptor", [] { TempDir temp; // Layout rule: the reader derives the letter dir from the candidate // FILENAME's first char — "compat.gadget.lua" lives under pkgs/c/. @@ -50,26 +52,26 @@ TEST(SearchVersions, MergesPerOsTablesOfTheHitsDescriptor) { mcpp::pm::Fetcher f(cfg); auto versions = f.versions_for_hit(hit("compat:gadget")); - ASSERT_EQ(versions.size(), 2u); - EXPECT_EQ(versions[0], "2.0.0"); - EXPECT_EQ(versions[1], "1.0.0"); -} + expect(fatal(eq(versions.size(), 2u))); + expect(eq(versions[0], "2.0.0")); + expect(eq(versions[1], "1.0.0")); +}}; -TEST(SearchVersions, BareNameWithoutNamespaceCarriesNoVersions) { +UTest bare_name_without_namespace_carries_no_versions{"SearchVersions/BareNameWithoutNamespaceCarriesNoVersions", [] { mcpp::config::GlobalConfig cfg{}; cfg.registryDir = std::filesystem::temp_directory_path() / "mcpp-search-versions-no-registry"; mcpp::pm::Fetcher f(cfg); - EXPECT_TRUE(f.versions_for_hit(hit("gadget")).empty()); -} + expect(f.versions_for_hit(hit("gadget")).empty()); +}}; -TEST(SearchVersions, UnreadableDescriptorDegradesToEmpty) { +UTest unreadable_descriptor_degrades_to_empty{"SearchVersions/UnreadableDescriptorDegradesToEmpty", [] { TempDir temp; mcpp::config::GlobalConfig cfg{}; cfg.registryDir = temp.path; // data/ exists nowhere: nothing readable mcpp::pm::Fetcher f(cfg); - EXPECT_TRUE(f.versions_for_hit(hit("compat:ghost")).empty()); -} + expect(f.versions_for_hit(hit("compat:ghost")).empty()); +}}; } // namespace diff --git a/tests/unit/test_select_sources.cpp b/tests/unit/test_select_sources.cpp index b39a90c17..c44da4491 100644 --- a/tests/unit/test_select_sources.cpp +++ b/tests/unit/test_select_sources.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.modgraph.scanner; +using namespace mcpplibs::mtest; + // A `sources` list selects every pattern's files less the files any `!` // pattern matches, whichever pattern selected them. The rows of // `[target..build] sources` are appended to the list, so a system's row @@ -30,25 +32,25 @@ struct Tree { }; } // namespace -TEST(SelectSources, AnExclusionRemovesWhatThePatternsSelected) { +UTest an_exclusion_removes_what_the_patterns_selected{"SelectSources/AnExclusionRemovesWhatThePatternsSelected", [] { Tree t; auto got = t.names(mcpp::modgraph::select_sources(t.root, { "src/**/*.cpp", "!src/**/*_test.cpp", "!src/native/**" })); - EXPECT_EQ(got, (std::vector{ "src/a.cpp" })); -} + expect(eq(got, (std::vector{ "src/a.cpp" }))); +}}; -TEST(SelectSources, ASystemsRowLeavesOutTheOtherSystemsDirectory) { +UTest a_systems_row_leaves_out_the_other_systems_directory{"SelectSources/ASystemsRowLeavesOutTheOtherSystemsDirectory", [] { Tree t; // The unconditional list, then the linux row. auto got = t.names(mcpp::modgraph::select_sources(t.root, { "src/**/*.cpp", "!src/native/windows/**" })); - EXPECT_EQ(got, (std::vector{ "src/a.cpp", "src/b_test.cpp", "src/native/posix/lock.cpp" })); -} + expect(eq(got, (std::vector{ "src/a.cpp", "src/b_test.cpp", "src/native/posix/lock.cpp" }))); +}}; -TEST(SelectSources, AnExclusionRemovesWhicheverPatternSelected) { +UTest an_exclusion_removes_whichever_pattern_selected{"SelectSources/AnExclusionRemovesWhicheverPatternSelected", [] { Tree t; // An exclusion written before the pattern that selects the file still // removes it, as published manifests rely on. auto got = t.names(mcpp::modgraph::select_sources(t.root, { "!src/native/**", "src/**/*.cpp", "src/native/posix/*.cpp" })); - EXPECT_EQ(got, (std::vector{ "src/a.cpp", "src/b_test.cpp" })); -} + expect(eq(got, (std::vector{ "src/a.cpp", "src/b_test.cpp" }))); +}}; diff --git a/tests/unit/test_short_name_scan.cpp b/tests/unit/test_short_name_scan.cpp index 11f73d2be..96efa24ec 100644 --- a/tests/unit/test_short_name_scan.cpp +++ b/tests/unit/test_short_name_scan.cpp @@ -3,11 +3,13 @@ // the suggested packages actually offer. Still DIAGNOSTIC ONLY: nothing here // feeds resolution, the lockfile, or the install layer. -#include import std; +import mcpplibs.mtest; import mcpp.pm.package_fetcher; +using namespace mcpplibs::mtest; + namespace { struct TempDir { @@ -45,7 +47,7 @@ std::string descriptor_with_versions(std::string_view ns, ns, name, xpmTable); } -TEST(ShortNameScan, CarriesMergedDescendingVersionsPerMatch) { +UTest carries_merged_descending_versions_per_match{"ShortNameScan/CarriesMergedDescendingVersionsPerMatch", [] { TempDir temp; write_descriptor(temp.path, "a.acme.widget.lua", descriptor_with_versions( "acme", "acme.widget", @@ -60,17 +62,17 @@ TEST(ShortNameScan, CarriesMergedDescendingVersionsPerMatch) { {temp.path}, "widget"); // Sorted by fqn; each record keeps its fqn AND its version union. - ASSERT_EQ(matches.size(), 1u); - EXPECT_EQ(matches[0].fqn, "acme.widget"); - ASSERT_EQ(matches[0].versions.size(), 2u); - EXPECT_EQ(matches[0].versions[0], "1.0.0"); - EXPECT_EQ(matches[0].versions[1], "0.9.0"); + expect(fatal(eq(matches.size(), 1u))); + expect(eq(matches[0].fqn, "acme.widget")); + expect(fatal(eq(matches[0].versions.size(), 2u))); + expect(eq(matches[0].versions[0], "1.0.0")); + expect(eq(matches[0].versions[1], "0.9.0")); // The short name is matched on the SHORT component only: compat/gadget // must not appear for "widget". -} +}}; -TEST(ShortNameScan, DescriptorWithoutXpmYieldsEmptyVersions) { +UTest descriptor_without_xpm_yields_empty_versions{"ShortNameScan/DescriptorWithoutXpmYieldsEmptyVersions", [] { TempDir temp; write_descriptor(temp.path, "a.acme.bare.lua", descriptor_with_versions( "acme", "acme.bare", "mcpp = { schema = \"0.1\" }")); @@ -78,12 +80,12 @@ TEST(ShortNameScan, DescriptorWithoutXpmYieldsEmptyVersions) { auto matches = mcpp::pm::Fetcher::scan_short_name_matches( {temp.path}, "bare"); - ASSERT_EQ(matches.size(), 1u); - EXPECT_EQ(matches[0].fqn, "acme.bare"); - EXPECT_TRUE(matches[0].versions.empty()); -} + expect(fatal(eq(matches.size(), 1u))); + expect(eq(matches[0].fqn, "acme.bare")); + expect(matches[0].versions.empty()); +}}; -TEST(ShortNameScan, EmptyResultOnNoMatchIsNotAnError) { +UTest empty_result_on_no_match_is_not_an_error{"ShortNameScan/EmptyResultOnNoMatchIsNotAnError", [] { TempDir temp; write_descriptor(temp.path, "a.acme.widget.lua", descriptor_with_versions( "acme", "acme.widget", @@ -92,7 +94,7 @@ TEST(ShortNameScan, EmptyResultOnNoMatchIsNotAnError) { auto matches = mcpp::pm::Fetcher::scan_short_name_matches( {temp.path}, "nosuchthing"); - EXPECT_TRUE(matches.empty()); -} + expect(matches.empty()); +}}; } // namespace diff --git a/tests/unit/test_source_digest.cpp b/tests/unit/test_source_digest.cpp index fdc3c050e..2e178076e 100644 --- a/tests/unit/test_source_digest.cpp +++ b/tests/unit/test_source_digest.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.source_digest; +using namespace mcpplibs::mtest; + // THE CONTENT DIGEST OF A SOURCE TREE, RE-READ ONLY WHERE A STAT MOVED // (mcpp#797, R6). A build program depends on every source of the packages in // its graph, an included header among them. @@ -28,26 +30,26 @@ struct Tree { }; } // namespace -TEST(SourceDigest, AnEditedHeaderChangesIt) { +UTest an_edited_header_changes_it{"SourceDigest/AnEditedHeaderChangesIt", [] { Tree t; t.write("src/lib.cppm", "module;\n#include \"v.h\"\nexport module lib;\n"); t.write("src/v.h", "#define V 1\n"); const auto a = t.digest(); - EXPECT_EQ(t.digest(), a) << "a tree that did not change keeps its digest"; + expect(eq(t.digest(), a)) << "a tree that did not change keeps its digest"; t.write("src/v.h", "#define V 2\n"); - EXPECT_NE(t.digest(), a); -} + expect(ne(t.digest(), a)); +}}; -TEST(SourceDigest, ATouchWithoutAnEditKeepsIt) { +UTest a_touch_without_an_edit_keeps_it{"SourceDigest/ATouchWithoutAnEditKeepsIt", [] { Tree t; t.write("src/v.h", "#define V 1\n"); const auto a = t.digest(); std::filesystem::last_write_time(t.root / "src/v.h", std::filesystem::last_write_time(t.root / "src/v.h") + std::chrono::seconds(5)); - EXPECT_EQ(t.digest(), a); -} + expect(eq(t.digest(), a)); +}}; -TEST(SourceDigest, BuildOutputAndNestedPackagesAreNotPartOfIt) { +UTest build_output_and_nested_packages_are_not_part_of_it{"SourceDigest/BuildOutputAndNestedPackagesAreNotPartOfIt", [] { Tree t; t.write("src/v.h", "#define V 1\n"); const auto a = t.digest(); @@ -55,16 +57,16 @@ TEST(SourceDigest, BuildOutputAndNestedPackagesAreNotPartOfIt) { t.write(".git/HEAD", "ref"); t.write("member/mcpp.toml", "[package]\n"); t.write("member/src/m.cppm", "export module m;\n"); - EXPECT_EQ(t.digest(), a); -} + expect(eq(t.digest(), a)); +}}; -TEST(SourceDigest, AnAddedOrRemovedFileChangesIt) { +UTest an_added_or_removed_file_changes_it{"SourceDigest/AnAddedOrRemovedFileChangesIt", [] { Tree t; t.write("src/a.cppm", "export module a;\n"); const auto a = t.digest(); t.write("src/b.cppm", "export module b;\n"); const auto b = t.digest(); - EXPECT_NE(b, a); + expect(ne(b, a)); std::filesystem::remove(t.root / "src/b.cppm"); - EXPECT_EQ(t.digest(), a); -} + expect(eq(t.digest(), a)); +}}; diff --git a/tests/unit/test_source_kind.cpp b/tests/unit/test_source_kind.cpp index 0ce2ccdae..82620e43f 100644 --- a/tests/unit/test_source_kind.cpp +++ b/tests/unit/test_source_kind.cpp @@ -1,28 +1,30 @@ -#include import std; +import mcpplibs.mtest; import mcpp.source_kind; import mcpp.toolchain.model; import mcpp.toolchain.detect; +using namespace mcpplibs::mtest; + using mcpp::SourceKind; // ─── T-1. classify() ─────────────────────────────────────────────────────── -TEST(SourceKind, BuiltInTableIsCppmOnly) { +UTest built_in_table_is_cppm_only{"SourceKind/BuiltInTableIsCppmOnly", [] { // Load-bearing, not conservatism. Widening the built-in set widens the // DEFAULT source glob with it, so a published package with a vendored // MSVC-only `.ixx` under src/ would start compiling it on the next mcpp // upgrade — a break its author cannot fix, because that version's tarball // has already shipped. auto t = mcpp::builtin_extension_table(); - EXPECT_EQ(t.moduleInterface, (std::vector{".cppm"})); - EXPECT_EQ(mcpp::classify("src/a.ixx", t), SourceKind::Other); - EXPECT_EQ(mcpp::classify("src/a.ccm", t), SourceKind::Other); - EXPECT_EQ(mcpp::classify("src/a.cxxm", t), SourceKind::Other); -} + expect(eq(t.moduleInterface, (std::vector{".cppm"}))); + expect(eq(mcpp::classify("src/a.ixx", t), SourceKind::Other)); + expect(eq(mcpp::classify("src/a.ccm", t), SourceKind::Other)); + expect(eq(mcpp::classify("src/a.cxxm", t), SourceKind::Other)); +}}; -TEST(SourceKind, ClassifiesEveryBuiltInRole) { +UTest classifies_every_built_in_role{"SourceKind/ClassifiesEveryBuiltInRole", [] { auto t = mcpp::builtin_extension_table(); struct Row { const char* path; SourceKind want; }; const Row rows[] = { @@ -44,128 +46,126 @@ TEST(SourceKind, ClassifiesEveryBuiltInRole) { {"src/README", SourceKind::Other}, }; for (auto const& r : rows) - EXPECT_EQ(mcpp::classify(r.path, t), r.want) << r.path; -} + expect(eq(mcpp::classify(r.path, t), r.want)) << r.path; +}}; -TEST(SourceKind, CaseIsNeverFolded) { +UTest case_is_never_folded{"SourceKind/CaseIsNeverFolded", [] { // `.S` and `.s` are DIFFERENT LANGUAGES here — `.S` goes through the C // preprocessor, `.s` does not. Any normalization that lowercases would // silently merge them, so extensions are compared literally. auto t = mcpp::builtin_extension_table(); - EXPECT_EQ(mcpp::classify("a.S", t), SourceKind::GasAsm); - EXPECT_EQ(mcpp::classify("a.s", t), SourceKind::GasAsm); - EXPECT_EQ(mcpp::normalize_extension(".IXX"), ".IXX"); - EXPECT_EQ(mcpp::classify("a.CPPM", t), SourceKind::Other); -} + expect(eq(mcpp::classify("a.S", t), SourceKind::GasAsm)); + expect(eq(mcpp::classify("a.s", t), SourceKind::GasAsm)); + expect(eq(mcpp::normalize_extension(".IXX"), ".IXX")); + expect(eq(mcpp::classify("a.CPPM", t), SourceKind::Other)); +}}; -TEST(SourceKind, ConfiguredExtensionsAreAdditiveAndNormalized) { +UTest configured_extensions_are_additive_and_normalized{"SourceKind/ConfiguredExtensionsAreAdditiveAndNormalized", [] { // `ixx` without a dot, a duplicate, and one already built in. auto t = mcpp::extension_table_for( std::vector{"ixx", ".ccm", ".ccm", ".cppm", " .cxxm "}); - EXPECT_EQ(t.moduleInterface, - (std::vector{".cppm", ".ixx", ".ccm", ".cxxm"})); - EXPECT_EQ(mcpp::classify("src/a.ixx", t), SourceKind::ModuleInterface); - EXPECT_EQ(mcpp::classify("src/a.ccm", t), SourceKind::ModuleInterface); - EXPECT_EQ(mcpp::classify("src/a.cxxm", t), SourceKind::ModuleInterface); + expect(eq(t.moduleInterface, (std::vector{".cppm", ".ixx", ".ccm", ".cxxm"}))); + expect(eq(mcpp::classify("src/a.ixx", t), SourceKind::ModuleInterface)); + expect(eq(mcpp::classify("src/a.ccm", t), SourceKind::ModuleInterface)); + expect(eq(mcpp::classify("src/a.cxxm", t), SourceKind::ModuleInterface)); // Built-in roles are untouched by the addition. - EXPECT_EQ(mcpp::classify("src/a.cpp", t), SourceKind::Cxx); - EXPECT_EQ(mcpp::classify("src/a.c", t), SourceKind::C); -} + expect(eq(mcpp::classify("src/a.cpp", t), SourceKind::Cxx)); + expect(eq(mcpp::classify("src/a.c", t), SourceKind::C)); +}}; -TEST(SourceKind, ReservedExtensionsAreRejectedNotSilentlyAccepted) { +UTest reserved_extensions_are_rejected_not_silently_accepted{"SourceKind/ReservedExtensionsAreRejectedNotSilentlyAccepted", [] { // Declaring `.c` a module interface has no legitimate use and would route // C files to the C++ module rule, failing somewhere that names neither the // file nor the key. A hard error, not a warning. for (auto const* bad : {".cpp", ".cc", ".cxx", ".c", ".m", ".mm", ".h", ".hpp", ".hh", ".hxx", ".S", ".s", ".asm"}) { auto err = mcpp::validate_module_extensions(std::vector{bad}); - ASSERT_TRUE(err.has_value()) << bad; - EXPECT_NE(err->find(bad), std::string::npos) << *err; + expect(fatal(err.has_value())) << bad; + if (auto&& failed = expect(ne(err->find(bad), std::string::npos)); !failed) failed << *err; } // An unusual-but-free extension is fine: mcpp tells the compiler what the // unit is rather than relying on the driver to recognize the suffix. - EXPECT_FALSE(mcpp::validate_module_extensions( - std::vector{".mpp", ".cppmi"}).has_value()); -} + expect(!(mcpp::validate_module_extensions( + std::vector{".mpp", ".cppmi"}).has_value())); +}}; -TEST(SourceKind, ValidationRejectsNonExtensionShapes) { +UTest validation_rejects_non_extension_shapes{"SourceKind/ValidationRejectsNonExtensionShapes", [] { for (auto const* bad : {"", " ", ".", "src/*.ixx", "a/b.ixx", "foo.bar.ixx"}) { - EXPECT_TRUE(mcpp::validate_module_extensions( + expect(mcpp::validate_module_extensions( std::vector{bad}).has_value()) << "accepted: " << bad; } -} +}}; -TEST(SourceKind, PredicatesAgreeWithTheKind) { - EXPECT_TRUE(mcpp::produces_bmi(SourceKind::ModuleInterface)); - EXPECT_TRUE(mcpp::links_unconditionally(SourceKind::ModuleInterface)); +UTest predicates_agree_with_the_kind{"SourceKind/PredicatesAgreeWithTheKind", [] { + expect(bool(mcpp::produces_bmi(SourceKind::ModuleInterface))); + expect(bool(mcpp::links_unconditionally(SourceKind::ModuleInterface))); for (auto k : {SourceKind::Cxx, SourceKind::C, SourceKind::GasAsm, SourceKind::NasmAsm, SourceKind::Header, SourceKind::Other}) { - EXPECT_FALSE(mcpp::produces_bmi(k)); - EXPECT_FALSE(mcpp::links_unconditionally(k)); + expect(!mcpp::produces_bmi(k)); + expect(!mcpp::links_unconditionally(k)); } // Scan-exempt: cannot contain import/module, so no P1689 scan. - EXPECT_TRUE(mcpp::is_scan_exempt(SourceKind::C)); - EXPECT_TRUE(mcpp::is_scan_exempt(SourceKind::GasAsm)); - EXPECT_TRUE(mcpp::is_scan_exempt(SourceKind::NasmAsm)); - EXPECT_FALSE(mcpp::is_scan_exempt(SourceKind::ModuleInterface)); - EXPECT_FALSE(mcpp::is_scan_exempt(SourceKind::Cxx)); + expect(mcpp::is_scan_exempt(SourceKind::C)); + expect(mcpp::is_scan_exempt(SourceKind::GasAsm)); + expect(mcpp::is_scan_exempt(SourceKind::NasmAsm)); + expect(!mcpp::is_scan_exempt(SourceKind::ModuleInterface)); + expect(!mcpp::is_scan_exempt(SourceKind::Cxx)); // The fast path's question: could editing this change the graph's SHAPE? // Assembly is absent on purpose — it has no import and no scanned include // graph, so editing one changes its object (ninja tracks that) and nothing // else. A NEW assembly file is a different question, answered by // program_inputs_stale. - EXPECT_TRUE(mcpp::affects_graph_shape(SourceKind::ModuleInterface)); - EXPECT_TRUE(mcpp::affects_graph_shape(SourceKind::Cxx)); - EXPECT_TRUE(mcpp::affects_graph_shape(SourceKind::C)); - EXPECT_TRUE(mcpp::affects_graph_shape(SourceKind::Header)); - EXPECT_FALSE(mcpp::affects_graph_shape(SourceKind::GasAsm)); - EXPECT_FALSE(mcpp::affects_graph_shape(SourceKind::Other)); -} - -TEST(SourceKind, DefaultGlobsAreDerivedFromTheTable) { + expect(bool(mcpp::affects_graph_shape(SourceKind::ModuleInterface))); + expect(bool(mcpp::affects_graph_shape(SourceKind::Cxx))); + expect(bool(mcpp::affects_graph_shape(SourceKind::C))); + expect(bool(mcpp::affects_graph_shape(SourceKind::Header))); + expect(!mcpp::affects_graph_shape(SourceKind::GasAsm)); + expect(!mcpp::affects_graph_shape(SourceKind::Other)); +}}; + +UTest default_globs_are_derived_from_the_table{"SourceKind/DefaultGlobsAreDerivedFromTheTable", [] { // Two hand-maintained copies of this list used to exist and had already // drifted apart (the staging fallback was missing all three assembly // extensions). Deriving them is what keeps a declared extension from being // classified but never FOUND. auto builtin = mcpp::default_source_globs(mcpp::builtin_extension_table()); - EXPECT_EQ(builtin, (std::vector{ + expect(eq(builtin, (std::vector{ "src/**/*.cppm", "src/**/*.cpp", "src/**/*.cc", "src/**/*.c", - "src/**/*.S", "src/**/*.s", "src/**/*.asm"})); + "src/**/*.S", "src/**/*.s", "src/**/*.asm"}))); auto wide = mcpp::default_source_globs( mcpp::extension_table_for(std::vector{".ixx"})); - EXPECT_EQ(wide.front(), "src/**/*.cppm"); - EXPECT_NE(std::ranges::find(wide, "src/**/*.ixx"), wide.end()); + expect(eq(wide.front(), "src/**/*.cppm")); + expect(ne(std::ranges::find(wide, "src/**/*.ixx"), wide.end())); - EXPECT_EQ(mcpp::default_source_globs_note(mcpp::builtin_extension_table()), - "sources [src/**/*.{cppm,cpp,cc,c,S,s,asm}]"); -} + expect(eq(mcpp::default_source_globs_note(mcpp::builtin_extension_table()), "sources [src/**/*.{cppm,cpp,cc,c,S,s,asm}]")); +}}; // ─── T-2. Object naming ──────────────────────────────────────────────────── -TEST(SourceKind, ObjectNamingIsMonotoneAndCollisionFree) { +UTest object_naming_is_monotone_and_collision_free{"SourceKind/ObjectNamingIsMonotoneAndCollisionFree", [] { // Historical names are FROZEN: an object's name is part of the internal // layout of a global cache entry, so renaming one without changing the // cache key produces a HIT on an entry that lacks the object the link then // asks for — a missing `.o` at link time, not a cache miss. - EXPECT_EQ(mcpp::object_naming_for("foo.cpp"), mcpp::ObjectNaming::Stem); - EXPECT_EQ(mcpp::object_naming_for("foo.cc"), mcpp::ObjectNaming::Stem); - EXPECT_EQ(mcpp::object_naming_for("foo.c"), mcpp::ObjectNaming::Stem); - EXPECT_EQ(mcpp::object_naming_for("foo.cppm"), mcpp::ObjectNaming::StemDotM); - EXPECT_EQ(mcpp::object_naming_for("foo.S"), mcpp::ObjectNaming::FullFilename); - EXPECT_EQ(mcpp::object_naming_for("foo.asm"), mcpp::ObjectNaming::FullFilename); + expect(eq(mcpp::object_naming_for("foo.cpp"), mcpp::ObjectNaming::Stem)); + expect(eq(mcpp::object_naming_for("foo.cc"), mcpp::ObjectNaming::Stem)); + expect(eq(mcpp::object_naming_for("foo.c"), mcpp::ObjectNaming::Stem)); + expect(eq(mcpp::object_naming_for("foo.cppm"), mcpp::ObjectNaming::StemDotM)); + expect(eq(mcpp::object_naming_for("foo.S"), mcpp::ObjectNaming::FullFilename)); + expect(eq(mcpp::object_naming_for("foo.asm"), mcpp::ObjectNaming::FullFilename)); // Everything a project can ADD gets the collision-proof form, so a new // extension can never change an existing object's name. for (auto const* ext : {"foo.ixx", "foo.ccm", "foo.cxxm", "foo.mpp"}) - EXPECT_EQ(mcpp::object_naming_for(ext), mcpp::ObjectNaming::FullFilename) + expect(eq(mcpp::object_naming_for(ext), mcpp::ObjectNaming::FullFilename)) << ext; -} +}}; -TEST(SourceKind, SameStemAcrossModuleExtensionsNeverCollides) { +UTest same_stem_across_module_extensions_never_collides{"SourceKind/SameStemAcrossModuleExtensionsNeverCollides", [] { // mcpp#272 proposed giving all four module extensions the `.m` prefix, // which makes `foo.cppm` and `foo.ccm` BOTH `foo.m.o`. The per-package // collision prefix cannot help: it mirrors the source DIRECTORY, and these @@ -185,7 +185,7 @@ TEST(SourceKind, SameStemAcrossModuleExtensionsNeverCollides) { for (auto const* f : {"foo.cppm", "foo.ccm", "foo.cxxm", "foo.ixx", "foo.cpp", "foo.S", "foo.s", "foo.asm"}) { auto n = name(f); - EXPECT_TRUE(seen.insert(n).second) << "collision: " << f << " -> " << n; + if (auto&& failed = expect(bool(seen.insert(n).second)); !failed) failed << "collision: " << f << " -> " << n; } // KNOWN GAP, deliberately pinned rather than asserted away: `foo.c` and @@ -193,13 +193,13 @@ TEST(SourceKind, SameStemAcrossModuleExtensionsNeverCollides) { // every C object, which is exactly the cache-layout change described // above and needs a cache-key revision to be safe. Tracked separately — // this assertion documents the gap so nobody "fixes" the test instead. - EXPECT_EQ(name("foo.c"), name("foo.cpp")) + expect(eq(name("foo.c"), name("foo.cpp"))) << "known gap closed? update this test and bump the cache key"; -} +}}; // ─── T-8. The module-interface language flag ─────────────────────────────── -TEST(SourceKind, ModuleInterfaceLangFlagIsPerCompilerAndNotInterchangeable) { +UTest module_interface_lang_flag_is_per_compiler_and_not_interchangeable{"SourceKind/ModuleInterfaceLangFlagIsPerCompilerAndNotInterchangeable", [] { // Measured 2026-08-11: `-x c++-module` makes GCC exit with "language // c++-module not recognized", and `-x c++` makes Clang emit a 174-byte // stub instead of a module BMI. The spelling is a property of the compiler @@ -209,12 +209,9 @@ TEST(SourceKind, ModuleInterfaceLangFlagIsPerCompilerAndNotInterchangeable) { tc.compiler = id; return mcpp::toolchain::bmi_traits(tc); }; - EXPECT_EQ(traits_for(mcpp::toolchain::CompilerId::GCC).moduleInterfaceLangFlag, - " -x c++"); - EXPECT_EQ(traits_for(mcpp::toolchain::CompilerId::Clang).moduleInterfaceLangFlag, - " -x c++-module"); - EXPECT_EQ(traits_for(mcpp::toolchain::CompilerId::MSVC).moduleInterfaceLangFlag, - " /interface /TP"); + expect(eq(traits_for(mcpp::toolchain::CompilerId::GCC).moduleInterfaceLangFlag, " -x c++")); + expect(eq(traits_for(mcpp::toolchain::CompilerId::Clang).moduleInterfaceLangFlag, " -x c++-module")); + expect(eq(traits_for(mcpp::toolchain::CompilerId::MSVC).moduleInterfaceLangFlag, " /interface /TP")); // Never empty: mcpp tells the driver EVERY time rather than tracking which // suffix each driver version happens to know. That table would expire with @@ -223,8 +220,8 @@ TEST(SourceKind, ModuleInterfaceLangFlagIsPerCompilerAndNotInterchangeable) { for (auto id : {mcpp::toolchain::CompilerId::GCC, mcpp::toolchain::CompilerId::Clang, mcpp::toolchain::CompilerId::MSVC}) - EXPECT_FALSE(traits_for(id).moduleInterfaceLangFlag.empty()); -} + expect(!(traits_for(id).moduleInterfaceLangFlag.empty())); +}}; // ─── T-6. Device translation units ───────────────────────────────────────── // @@ -234,27 +231,27 @@ TEST(SourceKind, ModuleInterfaceLangFlagIsPerCompilerAndNotInterchangeable) { // about the language, which is what lets one kind cover CUDA C++, HIP, and // dialects that are not C++ at all. -TEST(SourceKind, ClassifiesDeviceTranslationUnits) { +UTest classifies_device_translation_units{"SourceKind/ClassifiesDeviceTranslationUnits", [] { auto t = mcpp::builtin_extension_table(); - EXPECT_EQ(mcpp::classify("src/k.cu", t), SourceKind::Device); - EXPECT_EQ(mcpp::classify("src/k.hip", t), SourceKind::Device); -} + expect(eq(mcpp::classify("src/k.cu", t), SourceKind::Device)); + expect(eq(mcpp::classify("src/k.hip", t), SourceKind::Device)); +}}; -TEST(SourceKind, ClassifiesSyclTranslationUnitsByCompilerNotDialect) { +UTest classifies_sycl_translation_units_by_compiler_not_dialect{"SourceKind/ClassifiesSyclTranslationUnitsByCompilerNotDialect", [] { // A `.sycl` unit is ordinary C++ and is still a device TU, because the // criterion is which compiler consumes it: a SYCL unit goes to icpx or a // clang with the SYCL front end, neither of which mcpp drives and neither // of which accepts C++20 modules. Naming it `.cpp` and routing it by glob // would make one extension mean two things depending on match order. auto t = mcpp::builtin_extension_table(); - EXPECT_EQ(mcpp::classify("src/kernels/saxpy.sycl", t), SourceKind::Device); - EXPECT_TRUE(mcpp::is_scan_exempt(SourceKind::Device)); + expect(eq(mcpp::classify("src/kernels/saxpy.sycl", t), SourceKind::Device)); + expect(mcpp::is_scan_exempt(SourceKind::Device)); // And the C++ spelling of the same content stays C++: adding the row must // not reclassify anything that is not literally named `.sycl`. - EXPECT_EQ(mcpp::classify("src/kernels/saxpy.cpp", t), SourceKind::Cxx); -} + expect(eq(mcpp::classify("src/kernels/saxpy.cpp", t), SourceKind::Cxx)); +}}; -TEST(SourceKind, ClassifiesShaderAndKernelLanguages) { +UTest classifies_shader_and_kernel_languages{"SourceKind/ClassifiesShaderAndKernelLanguages", [] { // The criterion is "a separate compiler consumes it", not "NVIDIA ships // it". Before this, the list was `.cu` and `.hip` alone, and a shader in a // constrained glob was refused with "no role for the extension '.comp'" — @@ -267,10 +264,10 @@ TEST(SourceKind, ClassifiesShaderAndKernelLanguages) { "shaders/s.rint", "shaders/s.rahit", "shaders/s.rchit", "shaders/s.rmiss", "shaders/s.rcall", "shaders/s.glsl", "shaders/s.hlsl", "kernels/k.cl", "kernels/k.metal"}) - EXPECT_EQ(mcpp::classify(p, t), SourceKind::Device) << p; -} + expect(eq(mcpp::classify(p, t), SourceKind::Device)) << p; +}}; -TEST(SourceKind, ShaderExtensionsDoNotWidenTheDefaultGlob) { +UTest shader_extensions_do_not_widen_the_default_glob{"SourceKind/ShaderExtensionsDoNotWidenTheDefaultGlob", [] { // The whole safety argument for widening the table rests on this: the // default globs are unchanged, so no published package starts compiling a // vendored shader on the next upgrade. Asserted over the WHOLE list rather @@ -281,72 +278,72 @@ TEST(SourceKind, ShaderExtensionsDoNotWidenTheDefaultGlob) { "src/**/*.cppm", "src/**/*.cpp", "src/**/*.cc", "src/**/*.c", "src/**/*.S", "src/**/*.s", "src/**/*.asm", }; - EXPECT_EQ(globs, expected); -} + expect(eq(globs, expected)); +}}; -TEST(SourceKind, ShaderExtensionsCannotBeClaimedAsModuleInterfaces) { +UTest shader_extensions_cannot_be_claimed_as_module_interfaces{"SourceKind/ShaderExtensionsCannotBeClaimedAsModuleInterfaces", [] { // Reserved for the same reason `.cu` is: routing a shader to the C++ // module rule fails somewhere that names neither the file nor the key. for (const char* ext : {".comp", ".hlsl", ".cl", ".metal", ".sycl"}) { auto err = mcpp::validate_module_extensions(std::vector{ext}); - ASSERT_TRUE(err.has_value()) << ext; - EXPECT_NE(err->find(ext), std::string::npos) << ext; + expect(fatal(err.has_value())) << ext; + expect(ne(err->find(ext), std::string::npos)) << ext; } -} +}}; -TEST(SourceKind, ShaderObjectsUseTheCollisionProofName) { +UTest shader_objects_use_the_collision_proof_name{"SourceKind/ShaderObjectsUseTheCollisionProofName", [] { // `scale.comp` and `scale.vert` are one rename apart in a shader // directory, and both would be `scale.o` under the stem-named form. - EXPECT_EQ(mcpp::object_filename_for("shaders/scale.comp", ".o"), "scale.comp.o"); - EXPECT_EQ(mcpp::object_filename_for("shaders/scale.vert", ".o"), "scale.vert.o"); -} + expect(eq(mcpp::object_filename_for("shaders/scale.comp", ".o"), "scale.comp.o")); + expect(eq(mcpp::object_filename_for("shaders/scale.vert", ".o"), "scale.vert.o")); +}}; -TEST(SourceKind, DeviceHeadersAffectGraphShape) { +UTest device_headers_affect_graph_shape{"SourceKind/DeviceHeadersAffectGraphShape", [] { // `.cuh` reaches a device TU through the preprocessor, so editing one can // change what the graph should be. Leaving it in `Other` is why a project // would see "edited the kernel header, nothing rebuilt". auto t = mcpp::builtin_extension_table(); - EXPECT_EQ(mcpp::classify("src/k.cuh", t), SourceKind::Header); - EXPECT_EQ(mcpp::classify("src/k.hiph", t), SourceKind::Header); - EXPECT_TRUE(mcpp::affects_graph_shape(SourceKind::Header)); -} + expect(eq(mcpp::classify("src/k.cuh", t), SourceKind::Header)); + expect(eq(mcpp::classify("src/k.hiph", t), SourceKind::Header)); + expect(bool(mcpp::affects_graph_shape(SourceKind::Header))); +}}; -TEST(SourceKind, DevicePredicates) { +UTest device_predicates{"SourceKind/DevicePredicates", [] { // Scan-exempt for the same reason assembly is: a device TU carries no // `import`, so there is nothing for P1689 to answer. - EXPECT_TRUE (mcpp::is_scan_exempt(SourceKind::Device)); - EXPECT_FALSE(mcpp::produces_bmi(SourceKind::Device)); - EXPECT_FALSE(mcpp::is_cxx_like(SourceKind::Device)); - EXPECT_FALSE(mcpp::links_unconditionally(SourceKind::Device)); + expect(mcpp::is_scan_exempt(SourceKind::Device)); + expect(!mcpp::produces_bmi(SourceKind::Device)); + expect(!mcpp::is_cxx_like(SourceKind::Device)); + expect(!mcpp::links_unconditionally(SourceKind::Device)); // Absent for the same reason assembly is absent: the content change is // tracked by ninja, and a NEW file is `program_inputs_stale`'s question. - EXPECT_FALSE(mcpp::affects_graph_shape(SourceKind::Device)); - EXPECT_EQ(mcpp::to_string(SourceKind::Device), "device"); -} + expect(!mcpp::affects_graph_shape(SourceKind::Device)); + expect(eq(mcpp::to_string(SourceKind::Device), "device")); +}}; -TEST(SourceKind, DeviceExtensionsAreNotInTheDefaultGlob) { +UTest device_extensions_are_not_in_the_default_glob{"SourceKind/DeviceExtensionsAreNotInTheDefaultGlob", [] { // Same compatibility argument as the built-in module-extension table: a // published package that vendors a `.cu` it builds elsewhere must not // start compiling it on the next mcpp upgrade. Device sources are opted // into by naming them in a `kind = "device"` target. auto globs = mcpp::default_source_globs(mcpp::builtin_extension_table()); for (auto const& g : globs) { - EXPECT_EQ(g.find(".cu"), std::string::npos) << g; - EXPECT_EQ(g.find(".hip"), std::string::npos) << g; + expect(eq(g.find(".cu"), std::string::npos)) << g; + expect(eq(g.find(".hip"), std::string::npos)) << g; } - EXPECT_EQ(mcpp::default_source_globs_note(mcpp::builtin_extension_table()) - .find("cu"), std::string::npos); -} + expect(eq(mcpp::default_source_globs_note(mcpp::builtin_extension_table()) + .find("cu"), std::string::npos)); +}}; -TEST(SourceKind, DeviceObjectsUseTheCollisionProofName) { +UTest device_objects_use_the_collision_proof_name{"SourceKind/DeviceObjectsUseTheCollisionProofName", [] { // `k.cu` and `k.cpp` in one directory are common in a mixed project; the // stem-named form would give both `k.o`. - EXPECT_EQ(mcpp::object_filename_for("src/k.cu", ".o"), "k.cu.o"); - EXPECT_EQ(mcpp::object_filename_for("src/k.hip", ".o"), "k.hip.o"); -} + expect(eq(mcpp::object_filename_for("src/k.cu", ".o"), "k.cu.o")); + expect(eq(mcpp::object_filename_for("src/k.hip", ".o"), "k.hip.o")); +}}; -TEST(SourceKind, DeviceExtensionsCannotBeClaimedAsModuleInterfaces) { +UTest device_extensions_cannot_be_claimed_as_module_interfaces{"SourceKind/DeviceExtensionsCannotBeClaimedAsModuleInterfaces", [] { auto err = mcpp::validate_module_extensions(std::vector{".cu"}); - ASSERT_TRUE(err.has_value()); - EXPECT_NE(err->find(".cu"), std::string::npos); -} + expect(fatal(err.has_value())); + expect(ne(err->find(".cu"), std::string::npos)); +}}; diff --git a/tests/unit/test_sources.cpp b/tests/unit/test_sources.cpp index b13fb7943..c5e6c17e2 100644 --- a/tests/unit/test_sources.cpp +++ b/tests/unit/test_sources.cpp @@ -2,9 +2,9 @@ // toolchain comes from, the protocol-15 directives a build program states // sources with, and the override checks of the payload unification. -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.config; import mcpp.build.build_program; @@ -14,6 +14,8 @@ import mcpp.toolchain.dialect; import mcpp.toolchain.registry; import mcpp.provider.address_set; +using namespace mcpplibs::mtest; + namespace dirs = mcpp::build::directives; namespace addrset = mcpp::provider::addrset; @@ -39,69 +41,69 @@ version = "0.1.0" // ── [xlings.overrides] ────────────────────────────────────────────────────── -TEST(Sources, OverridePathAndTableFormsParse) { +UTest override_path_and_table_forms_parse{"Sources/OverridePathAndTableFormsParse", [] { auto m = manifest(R"( [xlings.overrides] "xim:cmake" = "/usr/bin/cmake" vcpkg = { root = "/opt/vcpkg" } "xim:slang" = { program = "slangc", version = "2026.14.1" } )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); using K = mcpp::manifest::XlingsOverride::Kind; const auto& o = m->xlings.overrides; - ASSERT_EQ(o.size(), 3u); - EXPECT_EQ(o.at("xim:cmake").kind, K::Path); - EXPECT_EQ(o.at("xim:cmake").value, "/usr/bin/cmake"); + expect(fatal(eq(o.size(), 3u))); + expect(eq(o.at("xim:cmake").kind, K::Path)); + expect(eq(o.at("xim:cmake").value, "/usr/bin/cmake")); // A key without a namespace names the `xim` package, as everywhere else. - EXPECT_EQ(o.at("xim:vcpkg").kind, K::Root); - EXPECT_EQ(o.at("xim:slang").kind, K::Program); - EXPECT_EQ(o.at("xim:slang").version, "2026.14.1"); - EXPECT_GT(o.at("xim:cmake").line, 0); -} + expect(eq(o.at("xim:vcpkg").kind, K::Root)); + expect(eq(o.at("xim:slang").kind, K::Program)); + expect(eq(o.at("xim:slang").version, "2026.14.1")); + expect(gt(o.at("xim:cmake").line, 0)); +}}; -TEST(Sources, OverrideKeyWithAVersionIsRefused) { +UTest override_key_with_a_version_is_refused{"Sources/OverrideKeyWithAVersionIsRefused", [] { auto m = manifest(R"( [xlings.overrides] "xim:cmake@3.31" = "/usr/bin/cmake" )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("names a version"), std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("names a version"), std::string::npos)); !failed) failed << m.error().message; +}}; -TEST(Sources, OverrideNamingBothProgramAndRootIsRefused) { +UTest override_naming_both_program_and_root_is_refused{"Sources/OverrideNamingBothProgramAndRootIsRefused", [] { auto m = manifest(R"( [xlings.overrides] cmake = { program = "/usr/bin/cmake", root = "/usr" } )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("exactly one of"), std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("exactly one of"), std::string::npos)); !failed) failed << m.error().message; +}}; -TEST(Sources, OverrideUnknownKeyIsRefused) { +UTest override_unknown_key_is_refused{"Sources/OverrideUnknownKeyIsRefused", [] { auto m = manifest(R"( [xlings.overrides] cmake = { programme = "/usr/bin/cmake" } )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("programme"), std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("programme"), std::string::npos)); !failed) failed << m.error().message; +}}; -TEST(Sources, OverrideUnderATargetSelectorParses) { +UTest override_under_a_target_selector_parses{"Sources/OverrideUnderATargetSelectorParses", [] { auto m = manifest(R"( [target.'cfg(os = "linux")'.xlings.overrides] "xim:cmake" = "/usr/bin/cmake" )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - ASSERT_FALSE(m->conditionalConfigs.empty()); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(fatal(!(m->conditionalConfigs.empty()))); bool found = false; for (auto const& cc : m->conditionalConfigs) if (cc.xlings.overrides.contains("xim:cmake")) found = true; - EXPECT_TRUE(found); -} + expect(bool(found)); +}}; // ── provision = "on-request" ──────────────────────────────────────────────── -TEST(Sources, ProvisionOnRequestIsRecordedOnBothTables) { +UTest provision_on_request_is_recorded_on_both_tables{"Sources/ProvisionOnRequestIsRecordedOnBothTables", [] { auto m = manifest(R"( [features] tools = {} @@ -111,131 +113,131 @@ tools = {} [feature-xlings.tools] "xim:slang" = { version = "", provision = "on-request", when = "build" } )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_TRUE(m->xlings.on_request("xim:cmake@>=3.31")); - EXPECT_FALSE(m->xlings.on_request("xim:ninja@1.12")); - EXPECT_TRUE(m->xlings.on_request("xim:slang")); - EXPECT_EQ(m->xlings.when_of("xim:slang"), mcpp::manifest::ToolWhen::Build); -} - -TEST(Sources, ProvisionUnknownModeIsRefused) { + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(bool(m->xlings.on_request("xim:cmake@>=3.31"))); + expect(!(m->xlings.on_request("xim:ninja@1.12"))); + expect(bool(m->xlings.on_request("xim:slang"))); + expect(eq(m->xlings.when_of("xim:slang"), mcpp::manifest::ToolWhen::Build)); +}}; + +UTest provision_unknown_mode_is_refused{"Sources/ProvisionUnknownModeIsRefused", [] { auto m = manifest(R"( [xlings.workspace] "xim:cmake" = { version = "", provision = "lazy" } )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("on-request"), std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("on-request"), std::string::npos)); !failed) failed << m.error().message; +}}; // ── [toolchain] tables ────────────────────────────────────────────────────── -TEST(Sources, ToolchainTableNamesAPath) { +UTest toolchain_table_names_a_path{"Sources/ToolchainTableNamesAPath", [] { auto m = manifest(R"( [toolchain] default = { path = "/opt/llvm", launcher = "ccache", tools = { ld = "/opt/lld/bin/ld.lld" } } bootstrap = "llvm@22.1.8" )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); - EXPECT_EQ(m->toolchain.for_platform("linux").value_or(""), "path:/opt/llvm"); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + expect(eq(m->toolchain.for_platform("linux").value_or(""), "path:/opt/llvm")); auto* lt = m->toolchain.local_for("linux"); - ASSERT_NE(lt, nullptr); - EXPECT_EQ(lt->launcher, "ccache"); - ASSERT_EQ(lt->tools.size(), 1u); - EXPECT_EQ(lt->tools[0].first, "ld"); - EXPECT_EQ(m->toolchain.bootstrap, "llvm@22.1.8"); + expect(fatal(ne(lt, nullptr))); + expect(eq(lt->launcher, "ccache")); + expect(fatal(eq(lt->tools.size(), 1u))); + expect(eq(lt->tools[0].first, "ld")); + expect(eq(m->toolchain.bootstrap, "llvm@22.1.8")); // `bootstrap` is not a platform. - EXPECT_FALSE(m->toolchain.byPlatform.contains("bootstrap")); -} + expect(!(m->toolchain.byPlatform.contains("bootstrap"))); +}}; -TEST(Sources, ToolchainConfigureIsAloneInItsTable) { +UTest toolchain_configure_is_alone_in_its_table{"Sources/ToolchainConfigureIsAloneInItsTable", [] { auto ok = manifest(R"( [toolchain] default = { configure = "build.mcpp" } )"); - ASSERT_TRUE(ok.has_value()) << ok.error().format(); - EXPECT_EQ(ok->toolchain.for_platform("macos").value_or(""), "configure:build.mcpp"); + if (auto&& failed = expect(fatal(ok.has_value())); !failed) failed << ok.error().format(); + expect(eq(ok->toolchain.for_platform("macos").value_or(""), "configure:build.mcpp")); auto both = manifest(R"( [toolchain] default = { configure = "build.mcpp", path = "/opt/llvm" } )"); - ASSERT_FALSE(both.has_value()); + expect(fatal(!both.has_value())); auto other = manifest(R"( [toolchain] default = { configure = "cmake" } )"); - ASSERT_FALSE(other.has_value()); - EXPECT_NE(other.error().message.find("build.mcpp"), std::string::npos) << other.error().message; -} + expect(fatal(!other.has_value())); + if (auto&& failed = expect(ne(other.error().message.find("build.mcpp"), std::string::npos)); !failed) failed << other.error().message; +}}; -TEST(Sources, ToolchainTableWithoutPathIsRefused) { +UTest toolchain_table_without_path_is_refused{"Sources/ToolchainTableWithoutPathIsRefused", [] { auto m = manifest(R"( [toolchain] linux = { prefix = "aarch64-none-linux-gnu-" } )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("path"), std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("path"), std::string::npos)); !failed) failed << m.error().message; +}}; -TEST(Sources, ToolchainUnknownToolRoleIsRefused) { +UTest toolchain_unknown_tool_role_is_refused{"Sources/ToolchainUnknownToolRoleIsRefused", [] { auto m = manifest(R"( [toolchain] default = { path = "/opt/llvm", tools = { linker = "/x" } } )"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().message.find("linker"), std::string::npos) << m.error().message; -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().message.find("linker"), std::string::npos)); !failed) failed << m.error().message; +}}; // ── Protocol-15 directives ────────────────────────────────────────────────── -TEST(Sources, SourceDirectivesFoldIntoTheBuildConfig) { +UTest source_directives_fold_into_the_build_config{"Sources/SourceDirectivesFoldIntoTheBuildConfig", [] { auto d = parse_directives( "mcpp:protocol=15\n" "mcpp:decision=tool:mcpp.deps.cmake:cmake\tchoice\t/usr/bin/cmake\t/p/build.mcpp\t9\txim:cmake\n" "mcpp:xpkg-request=xim:cmake\n" "mcpp:toolchain=path=/opt/llvm\n" "mcpp:toolchain=origin=/p/build.mcpp:4\n"); - EXPECT_FALSE(dirs::protocol_error(d).has_value()); + expect(!(dirs::protocol_error(d).has_value())); mcpp::manifest::Manifest m; dirs::apply(m, d); - ASSERT_EQ(m.buildConfig.toolDecisions.size(), 1u); - EXPECT_TRUE(m.buildConfig.toolDecisions[0].starts_with("tool:mcpp.deps.cmake:cmake\tchoice")); - ASSERT_EQ(m.buildConfig.xpkgRequests.size(), 1u); - EXPECT_EQ(m.buildConfig.xpkgRequests[0], "xim:cmake"); - ASSERT_EQ(m.buildConfig.toolchainStatement.size(), 2u); - EXPECT_EQ(m.buildConfig.toolchainStatement[0], "path=/opt/llvm"); -} - -TEST(Sources, SourceDirectivesAreProtocolFifteen) { + expect(fatal(eq(m.buildConfig.toolDecisions.size(), 1u))); + expect(m.buildConfig.toolDecisions[0].starts_with("tool:mcpp.deps.cmake:cmake\tchoice")); + expect(fatal(eq(m.buildConfig.xpkgRequests.size(), 1u))); + expect(eq(m.buildConfig.xpkgRequests[0], "xim:cmake")); + expect(fatal(eq(m.buildConfig.toolchainStatement.size(), 2u))); + expect(eq(m.buildConfig.toolchainStatement[0], "path=/opt/llvm")); +}}; + +UTest source_directives_are_protocol_fifteen{"Sources/SourceDirectivesAreProtocolFifteen", [] { for (auto wire : {"decision", "xpkg-request", "toolchain"}) { auto def = dirs::find_by_wire(wire); - ASSERT_NE(def, nullptr) << wire; - EXPECT_EQ(def->sinceProtocol, 15) << wire; - EXPECT_FALSE(def->tag.empty()) << wire; + expect(fatal(ne(def, nullptr))) << wire; + expect(eq(def->sinceProtocol, 15)) << wire; + expect(!(def->tag.empty())) << wire; } -} +}}; // ── Override checks in the unification ───────────────────────────────────── -TEST(Sources, OverrideVersionIsCheckedAgainstRequirements) { +UTest override_version_is_checked_against_requirements{"Sources/OverrideVersionIsCheckedAgainstRequirements", [] { std::vector claims{ {"xim:cmake@>=3.31", "mcpp:plugins", 1}, {"xim:cmake", "this project", 0}, }; - EXPECT_FALSE(addrset::override_violation(claims, "xim:cmake", "3.31.6", "[xlings.overrides]")); + expect(!addrset::override_violation(claims, "xim:cmake", "3.31.6", "[xlings.overrides]")); auto bad = addrset::override_violation(claims, "xim:cmake", "3.28.3", "[xlings.overrides]"); - ASSERT_TRUE(bad.has_value()); - EXPECT_NE(bad->find(">=3.31"), std::string::npos) << *bad; - EXPECT_NE(bad->find("mcpp:plugins"), std::string::npos) << *bad; + expect(fatal(bad.has_value())); + if (auto&& failed = expect(ne(bad->find(">=3.31"), std::string::npos)); !failed) failed << *bad; + if (auto&& failed = expect(ne(bad->find("mcpp:plugins"), std::string::npos)); !failed) failed << *bad; // No stated version: nothing to compare, and the requirement is listed. - EXPECT_FALSE(addrset::override_violation(claims, "xim:cmake", "", "[xlings.overrides]")); + expect(!addrset::override_violation(claims, "xim:cmake", "", "[xlings.overrides]")); auto reqs = addrset::requirements_for(claims, "xim:cmake"); - ASSERT_EQ(reqs.size(), 1u); - EXPECT_EQ(reqs[0], ">=3.31 by mcpp:plugins"); -} + expect(fatal(eq(reqs.size(), 1u))); + expect(eq(reqs[0], ">=3.31 by mcpp:plugins")); +}}; // ── The response file a long compile command goes through ─────────────────── -TEST(Sources, ResponseFileQuotesForTheWindowsTokenizer) { +UTest response_file_quotes_for_the_windows_tokenizer{"Sources/ResponseFileQuotesForTheWindowsTokenizer", [] { const std::vector args{ "-std=c++23", "/Tp C:/Program Files/x/build.mcpp", @@ -243,16 +245,16 @@ TEST(Sources, ResponseFileQuotesForTheWindowsTokenizer) { "C:\\with space\\dir\\", }; const auto body = mcpp::build::response_file_body(args, /*gnuQuoting=*/false); - EXPECT_EQ(std::ranges::count(body, '\n'), 4); - EXPECT_NE(body.find("-std=c++23\n"), std::string::npos) << body; - EXPECT_NE(body.find("\"/Tp C:/Program Files/x/build.mcpp\"\n"), std::string::npos) << body; - EXPECT_NE(body.find("\"-DNAME=\\\"v\\\"\"\n"), std::string::npos) << body; + expect(eq(std::ranges::count(body, '\n'), 4)); + expect(ne(body.find("-std=c++23\n"), std::string::npos)) << body; + expect(ne(body.find("\"/Tp C:/Program Files/x/build.mcpp\"\n"), std::string::npos)) << body; + expect(ne(body.find("\"-DNAME=\\\"v\\\"\"\n"), std::string::npos)) << body; // The run of backslashes that ends the argument is doubled, so it does not // escape the closing quote. - EXPECT_NE(body.find("\"C:\\with space\\dir\\\\\"\n"), std::string::npos) << body; -} + expect(ne(body.find("\"C:\\with space\\dir\\\\\"\n"), std::string::npos)) << body; +}}; -TEST(Sources, ResponseFileKeepsBackslashesLiteralForTheGnuTokenizer) { +UTest response_file_keeps_backslashes_literal_for_the_gnu_tokenizer{"Sources/ResponseFileKeepsBackslashesLiteralForTheGnuTokenizer", [] { // clang and GCC read a backslash as an escape, inside quotes as well as // outside, so a Windows path survives only when every backslash is doubled. // Measured with clang 22.1.8: `'-DX=a\b'` in a response file yields `X=ab`, @@ -264,60 +266,60 @@ TEST(Sources, ResponseFileKeepsBackslashesLiteralForTheGnuTokenizer) { R"(-DNAME="v")", }; const auto body = mcpp::build::response_file_body(args, /*gnuQuoting=*/true); - EXPECT_EQ(std::ranges::count(body, '\n'), 4); - EXPECT_NE(body.find("-fmodule-file=mcpp=D:\\\\a\\\\p\\\\mcpp.pcm\n"), std::string::npos) << body; - EXPECT_NE(body.find("D:\\\\a\\\\obj\\\\x.o\n"), std::string::npos) << body; + expect(eq(std::ranges::count(body, '\n'), 4)); + expect(ne(body.find("-fmodule-file=mcpp=D:\\\\a\\\\p\\\\mcpp.pcm\n"), std::string::npos)) << body; + expect(ne(body.find("D:\\\\a\\\\obj\\\\x.o\n"), std::string::npos)) << body; // Whitespace is handled by quoting the whole argument; the doubling holds // inside the quotes too. - EXPECT_NE(body.find("\"/Tp D:\\\\a\\\\b c\\\\build.mcpp\"\n"), std::string::npos) << body; + expect(ne(body.find("\"/Tp D:\\\\a\\\\b c\\\\build.mcpp\"\n"), std::string::npos)) << body; // A quote of the argument's own is escaped. - EXPECT_NE(body.find("-DNAME=\\\"v\\\"\n"), std::string::npos) << body; -} + expect(ne(body.find("-DNAME=\\\"v\\\"\n"), std::string::npos)) << body; +}}; // ── [xlings.overrides] in config.toml ─────────────────────────────────────── -TEST(Sources, ConfigOverridesParseWithTheManifestsTwoShapes) { +UTest config_overrides_parse_with_the_manifests_two_shapes{"Sources/ConfigOverridesParseWithTheManifestsTwoShapes", [] { auto doc = mcpp::libs::toml::parse(R"( [xlings.overrides] "xim:cmake" = "/usr/bin/cmake" vcpkg = { root = "/opt/vcpkg" } "xim:slang" = { program = "slangc", version = "2026.14.1" } )"); - ASSERT_TRUE(doc.has_value()); + expect(fatal(doc.has_value())); auto o = mcpp::config::parse_payload_overrides(*doc); - ASSERT_TRUE(o.has_value()) << o.error(); - ASSERT_EQ(o->size(), 3u); - EXPECT_EQ(o->at("xim:cmake").kind, "path"); - EXPECT_EQ(o->at("xim:cmake").value, "/usr/bin/cmake"); + if (auto&& failed = expect(fatal(o.has_value())); !failed) failed << o.error(); + expect(fatal(eq(o->size(), 3u))); + expect(eq(o->at("xim:cmake").kind, "path")); + expect(eq(o->at("xim:cmake").value, "/usr/bin/cmake")); // A key without a namespace names the `xim` package, as everywhere else. - EXPECT_EQ(o->at("xim:vcpkg").kind, "root"); - EXPECT_EQ(o->at("xim:slang").kind, "program"); - EXPECT_EQ(o->at("xim:slang").version, "2026.14.1"); - EXPECT_GT(o->at("xim:cmake").line, 0); -} + expect(eq(o->at("xim:vcpkg").kind, "root")); + expect(eq(o->at("xim:slang").kind, "program")); + expect(eq(o->at("xim:slang").version, "2026.14.1")); + expect(gt(o->at("xim:cmake").line, 0)); +}}; -TEST(Sources, ConfigOverrideWithAnUnknownKeyIsRefused) { +UTest config_override_with_an_unknown_key_is_refused{"Sources/ConfigOverrideWithAnUnknownKeyIsRefused", [] { auto doc = mcpp::libs::toml::parse(R"( [xlings.overrides] cmake = { programme = "/usr/bin/cmake" } )"); - ASSERT_TRUE(doc.has_value()); + expect(fatal(doc.has_value())); auto o = mcpp::config::parse_payload_overrides(*doc); - ASSERT_FALSE(o.has_value()); - EXPECT_NE(o.error().find("programme"), std::string::npos) << o.error(); -} + expect(fatal(!o.has_value())); + if (auto&& failed = expect(ne(o.error().find("programme"), std::string::npos)); !failed) failed << o.error(); +}}; -TEST(Sources, ConfigWithoutTheTableHasNoOverrides) { +UTest config_without_the_table_has_no_overrides{"Sources/ConfigWithoutTheTableHasNoOverrides", [] { auto doc = mcpp::libs::toml::parse("[toolchain]\ndefault = \"gcc@16.1.0\"\n"); - ASSERT_TRUE(doc.has_value()); + expect(fatal(doc.has_value())); auto o = mcpp::config::parse_payload_overrides(*doc); - ASSERT_TRUE(o.has_value()) << o.error(); - EXPECT_TRUE(o->empty()); -} + if (auto&& failed = expect(fatal(o.has_value())); !failed) failed << o.error(); + expect(o->empty()); +}}; // ── A toolchain named by path ─────────────────────────────────────────────── -TEST(Sources, PathSpecTakesTheFamilyFromTheDrivers) { +UTest path_spec_takes_the_family_from_the_drivers{"Sources/PathSpecTakesTheFamilyFromTheDrivers", [] { const auto base = std::filesystem::temp_directory_path() / "mcpp-test-sources-tc"; std::filesystem::remove_all(base); std::filesystem::create_directories(base / "llvm" / "bin"); @@ -325,32 +327,30 @@ TEST(Sources, PathSpecTakesTheFamilyFromTheDrivers) { std::ofstream(base / "llvm" / "bin" / "clang++") << ""; std::ofstream(base / "gcc" / "bin" / "aarch64-none-linux-gnu-g++") << ""; auto llvm = mcpp::toolchain::parse_toolchain_spec("path:" + (base / "llvm").string()); - ASSERT_TRUE(llvm.has_value()) << llvm.error(); - EXPECT_EQ(llvm->family, mcpp::toolchain::Family::Llvm); - EXPECT_EQ(llvm->spec_str(), "path:" + (base / "llvm").lexically_normal().generic_string()); + if (auto&& failed = expect(fatal(llvm.has_value())); !failed) failed << llvm.error(); + expect(eq(llvm->family, mcpp::toolchain::Family::Llvm)); + expect(eq(llvm->spec_str(), "path:" + (base / "llvm").lexically_normal().generic_string())); auto gcc = mcpp::toolchain::parse_toolchain_spec("path:" + (base / "gcc").string()); - ASSERT_TRUE(gcc.has_value()) << gcc.error(); - EXPECT_EQ(gcc->family, mcpp::toolchain::Family::Gcc); + if (auto&& failed = expect(fatal(gcc.has_value())); !failed) failed << gcc.error(); + expect(eq(gcc->family, mcpp::toolchain::Family::Gcc)); auto none = mcpp::toolchain::parse_toolchain_spec("path:" + (base / "missing").string()); - EXPECT_FALSE(none.has_value()); + expect(!none.has_value()); std::filesystem::remove_all(base); -} +}}; // ── The engine floor read from a manifest that does not parse ─────────────── -TEST(Sources, StatedMcppFloorIsReadFromPackageOnly) { +UTest stated_mcpp_floor_is_read_from_package_only{"Sources/StatedMcppFloorIsReadFromPackageOnly", [] { using mcpp::manifest::stated_mcpp_floor; - EXPECT_EQ(stated_mcpp_floor("[package]\nname = \"p\"\nmcpp = \">=2026.10.1.3\"\n"), - "2026.10.1.3"); + expect(eq(stated_mcpp_floor("[package]\nname = \"p\"\nmcpp = \">=2026.10.1.3\"\n"), "2026.10.1.3")); // A bare version is the same statement. - EXPECT_EQ(stated_mcpp_floor("[package]\nmcpp = \"2026.9.28.3\"\n"), "2026.9.28.3"); + expect(eq(stated_mcpp_floor("[package]\nmcpp = \"2026.9.28.3\"\n"), "2026.9.28.3")); // Only `[package]`: a dependency named mcpp states a dependency, not a floor. - EXPECT_EQ(stated_mcpp_floor("[package]\nname = \"p\"\n\n[dependencies]\nmcpp = \"1.0\"\n"), - ""); + expect(eq(stated_mcpp_floor("[package]\nname = \"p\"\n\n[dependencies]\nmcpp = \"1.0\"\n"), "")); // `[package.metadata]` is another table. - EXPECT_EQ(stated_mcpp_floor("[package.metadata]\nmcpp = \"9.9.9.9\"\n"), ""); + expect(eq(stated_mcpp_floor("[package.metadata]\nmcpp = \"9.9.9.9\"\n"), "")); // A key that merely starts with the name is not the key. - EXPECT_EQ(stated_mcpp_floor("[package]\nmcpp_home = \"/x\"\n"), ""); - EXPECT_EQ(stated_mcpp_floor("[package]\n# mcpp = \"9.9.9.9\"\n"), ""); - EXPECT_EQ(stated_mcpp_floor(""), ""); -} + expect(eq(stated_mcpp_floor("[package]\nmcpp_home = \"/x\"\n"), "")); + expect(eq(stated_mcpp_floor("[package]\n# mcpp = \"9.9.9.9\"\n"), "")); + expect(eq(stated_mcpp_floor(""), "")); +}}; diff --git a/tests/unit/test_static_placement.cpp b/tests/unit/test_static_placement.cpp index a457f8b1a..69920c709 100644 --- a/tests/unit/test_static_placement.cpp +++ b/tests/unit/test_static_placement.cpp @@ -10,11 +10,13 @@ // // Package 0 is the root. `shared` maps each shared package to its image count. -#include import std; +import mcpplibs.mtest; import mcpp.build.plan; +using namespace mcpplibs::mtest; + using mcpp::build::place_static_packages; namespace { @@ -24,87 +26,87 @@ using Edges = std::map>; } // namespace // root -> fw (shared) -> x: x belongs in fw. -TEST(StaticPlacement, AStaticPackageUnderOneSharedLibraryGoesIntoIt) { +UTest a_static_package_under_one_shared_library_goes_into_it{"StaticPlacement/AStaticPackageUnderOneSharedLibraryGoesIntoIt", [] { Edges edges{{0, {1}}, {1, {2}}}; auto p = place_static_packages(edges, {{1, 1}}, {}); - ASSERT_EQ(p.intoImage.size(), 1u); - EXPECT_EQ(p.intoImage.at(2), 1u); - EXPECT_TRUE(p.conflicts.empty()); -} + expect(fatal(eq(p.intoImage.size(), 1u))); + expect(eq(p.intoImage.at(2), 1u)); + expect(p.conflicts.empty()); +}}; // root -> fw -> a -> x, fw -> x: the whole static closure goes into fw. -TEST(StaticPlacement, ADiamondInsideOneImageStaysInThatImage) { +UTest a_diamond_inside_one_image_stays_in_that_image{"StaticPlacement/ADiamondInsideOneImageStaysInThatImage", [] { Edges edges{{0, {1}}, {1, {2, 3}}, {2, {3}}}; auto p = place_static_packages(edges, {{1, 1}}, {}); - EXPECT_EQ(p.intoImage.size(), 2u); - EXPECT_EQ(p.intoImage.at(2), 1u); - EXPECT_EQ(p.intoImage.at(3), 1u); - EXPECT_TRUE(p.conflicts.empty()); -} + expect(eq(p.intoImage.size(), 2u)); + expect(eq(p.intoImage.at(2), 1u)); + expect(eq(p.intoImage.at(3), 1u)); + expect(p.conflicts.empty()); +}}; // root -> fw1 -> x, root -> fw2 -> x: x has no single image. -TEST(StaticPlacement, TwoImagesReachingOnePackageIsAConflict) { +UTest two_images_reaching_one_package_is_a_conflict{"StaticPlacement/TwoImagesReachingOnePackageIsAConflict", [] { Edges edges{{0, {1, 2}}, {1, {3}}, {2, {3}}}; auto p = place_static_packages(edges, {{1, 1}, {2, 1}}, {}); - EXPECT_TRUE(p.intoImage.empty()); - ASSERT_EQ(p.conflicts.size(), 1u); - EXPECT_EQ(p.conflicts[0].package, 3u); - EXPECT_EQ(p.conflicts[0].images, (std::vector{1, 2})); - EXPECT_FALSE(p.conflicts[0].root); -} + expect(p.intoImage.empty()); + expect(fatal(eq(p.conflicts.size(), 1u))); + expect(eq(p.conflicts[0].package, 3u)); + expect(eq(p.conflicts[0].images, (std::vector{1, 2}))); + expect(!(p.conflicts[0].root)); +}}; // root -> fw -> x and root -> x: the program and the library both need it. -TEST(StaticPlacement, ThePackageTheRootAlsoLinksIsAConflictNamingTheRoot) { +UTest the_package_the_root_also_links_is_a_conflict_naming_the_root{"StaticPlacement/ThePackageTheRootAlsoLinksIsAConflictNamingTheRoot", [] { Edges edges{{0, {1, 2}}, {1, {2}}}; auto p = place_static_packages(edges, {{1, 1}}, {}); - EXPECT_TRUE(p.intoImage.empty()); - ASSERT_EQ(p.conflicts.size(), 1u); - EXPECT_EQ(p.conflicts[0].package, 2u); - EXPECT_TRUE(p.conflicts[0].root); -} + expect(p.intoImage.empty()); + expect(fatal(eq(p.conflicts.size(), 1u))); + expect(eq(p.conflicts[0].package, 2u)); + expect(bool(p.conflicts[0].root)); +}}; // root (itself the shared image) -> x: nothing moves; the root's images // already take every static package they reach. -TEST(StaticPlacement, ARootOwnedImageKeepsItsStaticPackages) { +UTest a_root_owned_image_keeps_its_static_packages{"StaticPlacement/ARootOwnedImageKeepsItsStaticPackages", [] { Edges edges{{0, {1}}}; auto p = place_static_packages(edges, {}, {}); - EXPECT_TRUE(p.intoImage.empty()); - EXPECT_TRUE(p.conflicts.empty()); -} + expect(p.intoImage.empty()); + expect(p.conflicts.empty()); +}}; // root -> fw1 -> fw2 -> x: a closure stops at the next shared package, so x // belongs to fw2 and not to fw1. -TEST(StaticPlacement, AClosureStopsAtTheNextSharedLibrary) { +UTest a_closure_stops_at_the_next_shared_library{"StaticPlacement/AClosureStopsAtTheNextSharedLibrary", [] { Edges edges{{0, {1}}, {1, {2}}, {2, {3}}}; auto p = place_static_packages(edges, {{1, 1}, {2, 1}}, {}); - ASSERT_EQ(p.intoImage.size(), 1u); - EXPECT_EQ(p.intoImage.at(3), 2u); - EXPECT_TRUE(p.conflicts.empty()); -} + expect(fatal(eq(p.intoImage.size(), 1u))); + expect(eq(p.intoImage.at(3), 2u)); + expect(p.conflicts.empty()); +}}; // A package with two shared targets is two images: its static closure has no // single home. -TEST(StaticPlacement, OnePackageWithTwoSharedTargetsIsTwoImages) { +UTest one_package_with_two_shared_targets_is_two_images{"StaticPlacement/OnePackageWithTwoSharedTargetsIsTwoImages", [] { Edges edges{{0, {1}}, {1, {2}}}; auto p = place_static_packages(edges, {{1, 2}}, {}); - EXPECT_TRUE(p.intoImage.empty()); - ASSERT_EQ(p.conflicts.size(), 1u); - EXPECT_EQ(p.conflicts[0].package, 2u); -} + expect(p.intoImage.empty()); + expect(fatal(eq(p.conflicts.size(), 1u))); + expect(eq(p.conflicts[0].package, 2u)); +}}; // A boundary (a distribution package, or a package providing a target layer) // is neither placed nor walked through. -TEST(StaticPlacement, ABoundaryIsNeitherPlacedNorTraversed) { +UTest a_boundary_is_neither_placed_nor_traversed{"StaticPlacement/ABoundaryIsNeitherPlacedNorTraversed", [] { Edges edges{{0, {1}}, {1, {2}}, {2, {3}}}; auto p = place_static_packages(edges, {{1, 1}}, {2}); - EXPECT_TRUE(p.intoImage.empty()); - EXPECT_TRUE(p.conflicts.empty()); -} + expect(p.intoImage.empty()); + expect(p.conflicts.empty()); +}}; // A header-only package placed into an image contributes no objects, and the // placement is still well defined. -TEST(StaticPlacement, APackageWithNoObjectsIsPlacedLikeAnyOther) { +UTest a_package_with_no_objects_is_placed_like_any_other{"StaticPlacement/APackageWithNoObjectsIsPlacedLikeAnyOther", [] { Edges edges{{0, {1}}, {1, {2}}}; auto p = place_static_packages(edges, {{1, 1}}, {}); - EXPECT_EQ(p.intoImage.at(2), 1u); -} + expect(eq(p.intoImage.at(2), 1u)); +}}; diff --git a/tests/unit/test_subos_info.cpp b/tests/unit/test_subos_info.cpp index 66daef297..b4d06babf 100644 --- a/tests/unit/test_subos_info.cpp +++ b/tests/unit/test_subos_info.cpp @@ -7,12 +7,14 @@ // that has been around a while, and silence there is what made mcpp#352 // expensive to find in the first place. -#include import std; +import mcpplibs.mtest; import mcpp.runtime.launch_env; import mcpp.provider.xlings.subos_info; +using namespace mcpplibs::mtest; + namespace su = mcpp::provider::xlings::subos; namespace { @@ -30,7 +32,7 @@ struct Tmp { } }; -TEST(SubosInfo, ReadsRuntimeAndEnvDeclarations) { +UTest reads_runtime_and_env_declarations{"SubosInfo/ReadsRuntimeAndEnvDeclarations", [] { Tmp t; t.write(R"({ "workspace": {}, @@ -64,26 +66,25 @@ TEST(SubosInfo, ReadsRuntimeAndEnvDeclarations) { } })"); auto info = su::read(t.dir); - EXPECT_TRUE(info.present); - EXPECT_EQ(info.schema, 1); - EXPECT_EQ(info.runtime, "glibc@2.39"); - EXPECT_EQ(info.hostGlibc, "2.43"); - ASSERT_EQ(info.providers.size(), 1u); - EXPECT_EQ(info.providers[0].binding, "mesa@25.0.7.1"); - ASSERT_EQ(info.providers[0].decls.size(), 2u); - EXPECT_EQ(info.providers[0].decls[0].var, "LIBGL_DRIVERS_PATH"); - ASSERT_EQ(info.runtimeProviders.size(), 1u); - EXPECT_EQ(info.runtimeProviders[0].capability, "render.demo"); - EXPECT_EQ(info.runtimeProviders[0].provider.namespace_, "xim"); - EXPECT_EQ(info.runtimeProviders[0].provider.name, "renderer"); - ASSERT_EQ(info.runtimeArtifacts.size(), 1u); - EXPECT_EQ(info.runtimeArtifacts[0].path, - (t.dir / "runtime/renderer.bin").lexically_normal()); - EXPECT_EQ(info.runtimeArtifacts[0].hostFingerprint, "host-2"); - EXPECT_TRUE(info.note.empty()); -} - -TEST(SubosInfo, OrdersGenericContractByTheCompleteFactIdentity) { + expect(bool(info.present)); + expect(eq(info.schema, 1)); + expect(eq(info.runtime, "glibc@2.39")); + expect(eq(info.hostGlibc, "2.43")); + expect(fatal(eq(info.providers.size(), 1u))); + expect(eq(info.providers[0].binding, "mesa@25.0.7.1")); + expect(fatal(eq(info.providers[0].decls.size(), 2u))); + expect(eq(info.providers[0].decls[0].var, "LIBGL_DRIVERS_PATH")); + expect(fatal(eq(info.runtimeProviders.size(), 1u))); + expect(eq(info.runtimeProviders[0].capability, "render.demo")); + expect(eq(info.runtimeProviders[0].provider.namespace_, "xim")); + expect(eq(info.runtimeProviders[0].provider.name, "renderer")); + expect(fatal(eq(info.runtimeArtifacts.size(), 1u))); + expect(eq(info.runtimeArtifacts[0].path, (t.dir / "runtime/renderer.bin").lexically_normal())); + expect(eq(info.runtimeArtifacts[0].hostFingerprint, "host-2")); + expect(info.note.empty()); +}}; + +UTest orders_generic_contract_by_the_complete_fact_identity{"SubosInfo/OrdersGenericContractByTheCompleteFactIdentity", [] { Tmp t; t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39", "runtime_contract":{ @@ -103,79 +104,77 @@ TEST(SubosInfo, OrdersGenericContractByTheCompleteFactIdentity) { ] }}})"); auto info = su::read(t.dir); - ASSERT_EQ(info.runtimeProviders.size(), 2u); - EXPECT_EQ(info.runtimeProviders[0].provider.source, "a-index"); - EXPECT_EQ(info.runtimeProviders[1].provider.source, "z-index"); - ASSERT_EQ(info.runtimeArtifacts.size(), 2u); - EXPECT_EQ(info.runtimeArtifacts[0].provenance, "a-view"); - EXPECT_EQ(info.runtimeArtifacts[1].provenance, "z-view"); -} + expect(fatal(eq(info.runtimeProviders.size(), 2u))); + expect(eq(info.runtimeProviders[0].provider.source, "a-index")); + expect(eq(info.runtimeProviders[1].provider.source, "z-index")); + expect(fatal(eq(info.runtimeArtifacts.size(), 2u))); + expect(eq(info.runtimeArtifacts[0].provenance, "a-view")); + expect(eq(info.runtimeArtifacts[1].provenance, "z-view")); +}}; // `${subosdir}` expands against the subos the block was read FROM, not // against any global notion of "the" subos. That is what lets a program // built under one subos run correctly under another. -TEST(SubosInfo, ResolvesSubosdirPlaceholder) { +UTest resolves_subosdir_placeholder{"SubosInfo/ResolvesSubosdirPlaceholder", [] { Tmp t; t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39", "envs":{"mesa@1":[ {"var":"LIBGL_DRIVERS_PATH","op":"prepend","value":"${subosdir}/usr/lib/dri"}]}}})"); auto env = su::resolve_env(su::read(t.dir), t.dir); - ASSERT_EQ(env.size(), 1u); - EXPECT_EQ(env[0].first, "LIBGL_DRIVERS_PATH"); + expect(fatal(eq(env.size(), 1u))); + expect(eq(env[0].first, "LIBGL_DRIVERS_PATH")); // Literal concatenation, NOT a path join. The separator in the declaration // belongs to the subos manifest and is substituted verbatim; turning it // into the host's would rewrite a value we do not own. On Windows the two // spellings differ and the path-join form is the wrong expectation. - EXPECT_EQ(env[0].second, t.dir.string() + "/usr/lib/dri"); -} + expect(eq(env[0].second, t.dir.string() + "/usr/lib/dri")); +}}; // Several providers may contribute to one variable — that is the normal // shape for the graphics stack, where mesa and a vendor bridge both add an // EGL vendor directory. `prepend` joins them; it must not drop either. -TEST(SubosInfo, PrependJoinsProvidersInOrder) { +UTest prepend_joins_providers_in_order{"SubosInfo/PrependJoinsProvidersInOrder", [] { Tmp t; t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":{ "a-mesa@1":[{"var":"V","op":"prepend","value":"${subosdir}/one"}], "b-vendor@1":[{"var":"V","op":"prepend","value":"${subosdir}/two"}]}}})"); auto env = su::resolve_env(su::read(t.dir), t.dir); - ASSERT_EQ(env.size(), 1u); + expect(fatal(eq(env.size(), 1u))); const auto sep = mcpp::runtime::launch_env::path_list_separator(); - EXPECT_EQ(env[0].second, - t.dir.string() + "/two" + sep + t.dir.string() + "/one") + expect(eq(env[0].second, t.dir.string() + "/two" + sep + t.dir.string() + "/one")) << "both providers must survive, later binding front-most"; -} +}}; // The same value arriving twice must not accumulate: nested invocations // would otherwise grow the variable without bound. -TEST(SubosInfo, PrependDeduplicates) { +UTest prepend_deduplicates{"SubosInfo/PrependDeduplicates", [] { Tmp t; t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":{ "a@1":[{"var":"V","op":"prepend","value":"${subosdir}/x"}], "b@1":[{"var":"V","op":"prepend","value":"${subosdir}/x"}]}}})"); auto env = su::resolve_env(su::read(t.dir), t.dir); - ASSERT_EQ(env.size(), 1u); + expect(fatal(eq(env.size(), 1u))); // One entry, not two. The de-duplication has to split on the PLATFORM's // list separator: keyed on ':' it would cut "C:\\x" apart on Windows, // match nothing, and grow the list on every nested invocation. - EXPECT_EQ(env[0].second, t.dir.string() + "/x"); - EXPECT_EQ(env[0].second.find(mcpp::runtime::launch_env::path_list_separator()), - std::string::npos); -} + expect(eq(env[0].second, t.dir.string() + "/x")); + expect(eq(env[0].second.find(mcpp::runtime::launch_env::path_list_separator()), std::string::npos)); +}}; // `set` supplies a value only when the caller did not define the variable. // Presence — not non-emptiness — is the boundary, matching xlings: an // explicitly empty export is still an intentional caller value. -TEST(SubosInfo, SetAppliesWhenAmbientIsAbsent) { +UTest set_applies_when_ambient_is_absent{"SubosInfo/SetAppliesWhenAmbientIsAbsent", [] { Tmp t; t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":{ "a@1":[{"var":"V","op":"prepend","value":"/one"}], "b@1":[{"var":"V","op":"set","value":"/two"}]}}})"); auto env = su::resolve_env(su::read(t.dir), t.dir); - ASSERT_EQ(env.size(), 1u); - EXPECT_EQ(env[0].second, "/two"); -} + expect(fatal(eq(env.size(), 1u))); + expect(eq(env[0].second, "/two")); +}}; -TEST(SubosInfo, SetPreservesPresentAmbientValue) { +UTest set_preserves_present_ambient_value{"SubosInfo/SetPreservesPresentAmbientValue", [] { Tmp t; t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":{ "a@1":[{"var":"V","op":"set","value":"/subos"}]}}})"); @@ -185,11 +184,11 @@ TEST(SubosInfo, SetPreservesPresentAmbientValue) { if (var == "V") return "/caller"; return std::nullopt; }); - ASSERT_EQ(env.size(), 1u); - EXPECT_EQ(env[0].second, "/caller"); -} + expect(fatal(eq(env.size(), 1u))); + expect(eq(env[0].second, "/caller")); +}}; -TEST(SubosInfo, SetPreservesExplicitlyEmptyAmbientValue) { +UTest set_preserves_explicitly_empty_ambient_value{"SubosInfo/SetPreservesExplicitlyEmptyAmbientValue", [] { Tmp t; t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":{ "a@1":[{"var":"V","op":"set","value":"/subos"}]}}})"); @@ -199,64 +198,64 @@ TEST(SubosInfo, SetPreservesExplicitlyEmptyAmbientValue) { if (var == "V") return std::string{}; return std::nullopt; }); - ASSERT_EQ(env.size(), 1u); - EXPECT_TRUE(env[0].second.empty()); -} + expect(fatal(eq(env.size(), 1u))); + expect(env[0].second.empty()); +}}; // A subos made before xlings grew the block. Degrade, and SAY SO — this is // the common case on an existing machine, and mcpp's own sandbox subos was // measured in exactly this state. -TEST(SubosInfo, MissingBlockDegradesWithANote) { +UTest missing_block_degrades_with_a_note{"SubosInfo/MissingBlockDegradesWithANote", [] { Tmp t; t.write(R"({"workspace":{}})"); auto info = su::read(t.dir); - EXPECT_FALSE(info.present); - EXPECT_TRUE(info.runtime.empty()); - EXPECT_FALSE(info.note.empty()); - EXPECT_TRUE(su::resolve_env(info, t.dir).empty()); -} + expect(!info.present); + expect(info.runtime.empty()); + expect(!info.note.empty()); + expect(su::resolve_env(info, t.dir).empty()); +}}; // A schema newer than we understand: read what we can, and say we are behind. // Refusing outright would make a newer xlings break an older mcpp, which is // the failure mode the index-floor incident already paid for once. -TEST(SubosInfo, NewerSchemaIsUsedAndAnnounced) { +UTest newer_schema_is_used_and_announced{"SubosInfo/NewerSchemaIsUsedAndAnnounced", [] { Tmp t; t.write(R"({"subos_info":{"schema_version":99,"runtime":"glibc@2.44","envs":[]}})"); auto info = su::read(t.dir); - EXPECT_TRUE(info.present); - EXPECT_EQ(info.runtime, "glibc@2.44"); - EXPECT_FALSE(info.note.empty()); -} + expect(bool(info.present)); + expect(eq(info.runtime, "glibc@2.44")); + expect(!info.note.empty()); +}}; -TEST(SubosInfo, NoFileAtAllIsNotACrash) { +UTest no_file_at_all_is_not_a_crash{"SubosInfo/NoFileAtAllIsNotACrash", [] { Tmp t; // nothing written auto info = su::read(t.dir); - EXPECT_FALSE(info.present); - EXPECT_FALSE(info.note.empty()); -} + expect(!info.present); + expect(!info.note.empty()); +}}; -TEST(SubosInfo, MalformedJsonDegrades) { +UTest malformed_json_degrades{"SubosInfo/MalformedJsonDegrades", [] { Tmp t; t.write("{ this is not json"); auto info = su::read(t.dir); - EXPECT_FALSE(info.present); - EXPECT_FALSE(info.note.empty()); -} + expect(!info.present); + expect(!info.note.empty()); +}}; // The family mapping is part of the cross-repo contract: xlings derives it // from the runtime string too, and the two must agree or a subos and a build // will disagree about what ABI they are talking about. -TEST(SubosInfo, FamilyOfMirrorsXlings) { - EXPECT_EQ(su::family_of("glibc@2.39"), "linux-x86_64-glibc"); - EXPECT_EQ(su::family_of("musl@1.2.5"), "linux-x86_64-musl"); - EXPECT_EQ(su::family_of("glibc@2.39", "aarch64"), "linux-aarch64-glibc"); - EXPECT_EQ(su::family_of("wasi-libc@1"), "wasm32-wasi"); - EXPECT_EQ(su::family_of("macos_sdk@14.0", "arm64"), "darwin-arm64"); - EXPECT_EQ(su::family_of("ucrt@10"), "windows-x86_64-ucrt"); - EXPECT_EQ(su::family_of("nonsense@1"), "unknown"); +UTest family_of_mirrors_xlings{"SubosInfo/FamilyOfMirrorsXlings", [] { + expect(eq(su::family_of("glibc@2.39"), "linux-x86_64-glibc")); + expect(eq(su::family_of("musl@1.2.5"), "linux-x86_64-musl")); + expect(eq(su::family_of("glibc@2.39", "aarch64"), "linux-aarch64-glibc")); + expect(eq(su::family_of("wasi-libc@1"), "wasm32-wasi")); + expect(eq(su::family_of("macos_sdk@14.0", "arm64"), "darwin-arm64")); + expect(eq(su::family_of("ucrt@10"), "windows-x86_64-ucrt")); + expect(eq(su::family_of("nonsense@1"), "unknown")); // No '@' at all is not a binding; it must not be read as one. - EXPECT_EQ(su::family_of("glibc"), "linux-x86_64-glibc"); -} + expect(eq(su::family_of("glibc"), "linux-x86_64-glibc")); +}}; // A VERBATIM capture of what a real xlings wrote, after `xlings install @@ -272,7 +271,7 @@ TEST(SubosInfo, FamilyOfMirrorsXlings) { // // A fixture composed from the same understanding as the parser cannot catch // that. Only one taken from the writer can. -TEST(SubosInfo, RealXlingsCapture) { +UTest real_xlings_capture{"SubosInfo/RealXlingsCapture", [] { Tmp t; t.write(R"({ "subos_info": { @@ -295,42 +294,41 @@ TEST(SubosInfo, RealXlingsCapture) { })"); auto info = su::read(t.dir); - ASSERT_TRUE(info.present); - EXPECT_EQ(info.runtime, "glibc@2.39"); - ASSERT_EQ(info.providers.size(), 2u); - EXPECT_EQ(info.providers[0].binding, "mesa@25.0.7.1"); - EXPECT_EQ(info.providers[1].binding, "nvidia-gl-host-link@0.1.1"); + expect(fatal(bool(info.present))); + expect(eq(info.runtime, "glibc@2.39")); + expect(fatal(eq(info.providers.size(), 2u))); + expect(eq(info.providers[0].binding, "mesa@25.0.7.1")); + expect(eq(info.providers[1].binding, "nvidia-gl-host-link@0.1.1")); auto env = su::resolve_env(info, t.dir); - ASSERT_EQ(env.size(), 3u) << "all three graphics variables must be produced"; + expect(fatal(eq(env.size(), 3u))) << "all three graphics variables must be produced"; std::map byVar; for (auto& [k, v] : env) byVar[k] = v; const auto sep = mcpp::runtime::launch_env::path_list_separator(); - EXPECT_EQ(byVar["LIBGL_DRIVERS_PATH"], t.dir.string() + "/usr/lib/dri"); - EXPECT_EQ(byVar["XDG_DATA_DIRS"], t.dir.string() + "/share"); + expect(eq(byVar["LIBGL_DRIVERS_PATH"], t.dir.string() + "/usr/lib/dri")); + expect(eq(byVar["XDG_DATA_DIRS"], t.dir.string() + "/share")); // Both providers name the same vendor directory; de-duplication must // leave exactly one, or libglvnd sees it twice and enumerates the device // twice -- which is a defect xlings hit on its own side. - EXPECT_EQ(byVar["__EGL_VENDOR_LIBRARY_DIRS"], - t.dir.string() + "/share/glvnd/egl_vendor.d"); - EXPECT_EQ(byVar["__EGL_VENDOR_LIBRARY_DIRS"].find(sep), std::string::npos); -} + expect(eq(byVar["__EGL_VENDOR_LIBRARY_DIRS"], t.dir.string() + "/share/glvnd/egl_vendor.d")); + expect(eq(byVar["__EGL_VENDOR_LIBRARY_DIRS"].find(sep), std::string::npos)); +}}; // xlings drops a declaration whose op it does not recognise. A reader more // permissive than its writer eventually applies something the writer meant to // reject, so this asserts the same refusal rather than a tolerant guess. -TEST(SubosInfo, UnknownOpIsDroppedLikeXlingsDrops) { +UTest unknown_op_is_dropped_like_xlings_drops{"SubosInfo/UnknownOpIsDroppedLikeXlingsDrops", [] { Tmp t; t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":{ "a@1":[{"var":"V","op":"append","value":"/nope"}, {"var":"W","op":"prepend","value":"/yes"}, {"var":"","op":"prepend","value":"/no-name"}]}}})"); auto env = su::resolve_env(su::read(t.dir), t.dir); - ASSERT_EQ(env.size(), 1u); - EXPECT_EQ(env[0].first, "W"); - EXPECT_EQ(env[0].second, "/yes"); -} + expect(fatal(eq(env.size(), 1u))); + expect(eq(env[0].first, "W")); + expect(eq(env[0].second, "/yes")); +}}; @@ -338,7 +336,7 @@ TEST(SubosInfo, UnknownOpIsDroppedLikeXlingsDrops) { // `prepend` prepends TO the caller's value rather than replacing it. Same // reason: the pair replaces the variable, so emitting the declared value alone // discards whatever search path the user had. -TEST(SubosResolveEnv, PrependKeepsTheExportedValue) { +UTest prepend_keeps_the_exported_value{"SubosResolveEnv/PrependKeepsTheExportedValue", [] { Tmp t; t.write(R"({"workspace":{},"subos_info":{"schema_version":1,"runtime":"glibc@2.39", "envs":{"glibc@2.39":[{"var":"LD_LIBRARY_PATH","op":"prepend","value":"/sub/lib"}]}}})"); @@ -348,13 +346,13 @@ TEST(SubosResolveEnv, PrependKeepsTheExportedValue) { if (v == "LD_LIBRARY_PATH") return std::string("/user/lib"); return std::nullopt; }); - ASSERT_EQ(out.size(), 1u); + expect(fatal(eq(out.size(), 1u))); const auto sep = mcpp::runtime::launch_env::path_list_separator(); - EXPECT_EQ(out[0].second, std::string("/sub/lib") + sep + "/user/lib"); -} + expect(eq(out[0].second, std::string("/sub/lib") + sep + "/user/lib")); +}}; // Already there: prepending again would grow the list on every nested run. -TEST(SubosResolveEnv, PrependIsIdempotentAgainstTheExportedValue) { +UTest prepend_is_idempotent_against_the_exported_value{"SubosResolveEnv/PrependIsIdempotentAgainstTheExportedValue", [] { Tmp t; t.write(R"({"workspace":{},"subos_info":{"schema_version":1,"runtime":"glibc@2.39", "envs":{"glibc@2.39":[{"var":"LD_LIBRARY_PATH","op":"prepend","value":"/sub/lib"}]}}})"); @@ -366,14 +364,14 @@ TEST(SubosResolveEnv, PrependIsIdempotentAgainstTheExportedValue) { if (v == "LD_LIBRARY_PATH") return existing; return std::nullopt; }); - ASSERT_EQ(out.size(), 1u); - EXPECT_EQ(out[0].second, existing); -} + expect(fatal(eq(out.size(), 1u))); + expect(eq(out[0].second, existing)); +}}; // xlings defines `set` as set-if-absent. mcpp must use the same presence // boundary or the exact same SubOS changes behaviour depending on which tool // launches the program. -TEST(SubosResolveEnv, SetYieldsToAnExportedValue) { +UTest set_yields_to_an_exported_value{"SubosResolveEnv/SetYieldsToAnExportedValue", [] { Tmp t; t.write(R"({"workspace":{},"subos_info":{"schema_version":1, "runtime":"glibc@2.39", @@ -384,8 +382,8 @@ TEST(SubosResolveEnv, SetYieldsToAnExportedValue) { if (v == "GALLIUM_DRIVER") return std::string("llvmpipe"); return std::nullopt; }); - ASSERT_EQ(out.size(), 1u); - EXPECT_EQ(out[0].second, "llvmpipe"); -} + expect(fatal(eq(out.size(), 1u))); + expect(eq(out[0].second, "llvmpipe")); +}}; } // namespace diff --git a/tests/unit/test_subsystem_contracts.cpp b/tests/unit/test_subsystem_contracts.cpp index bf7b208e6..950cd7bb4 100644 --- a/tests/unit/test_subsystem_contracts.cpp +++ b/tests/unit/test_subsystem_contracts.cpp @@ -1,14 +1,16 @@ -#include #include #include #include import std; +import mcpplibs.mtest; import mcpp.version; import mcpp.build.program_protocol; import mcpp.build.hostprogram; +using namespace mcpplibs::mtest; + // GLOBAL CROSS-CHECK. // // The packages under `modules/` carry their own tests, which see only the @@ -30,7 +32,7 @@ import mcpp.build.hostprogram; namespace pp = mcpp::build::program_protocol; -TEST(SubsystemContracts, TheAnnouncedProtocolIsSubstitutedAndNotHardcoded) { +UTest the_announced_protocol_is_substituted_and_not_hardcoded{"SubsystemContracts/TheAnnouncedProtocolIsSubstitutedAndNotHardcoded", [] { // The bundled `mcpp` module announces `mcpp:protocol=N` so the engine can // reject a program written for a newer protocol. N is injected by // substituting `@PROTOCOL@` at compile time, PRECISELY so the announced @@ -40,28 +42,28 @@ TEST(SubsystemContracts, TheAnnouncedProtocolIsSubstitutedAndNotHardcoded) { // subsystem's own tests stay green while a bumped protocol silently keeps // announcing the old one. const std::string src{mcpp::build::kMcppModuleSource}; - EXPECT_NE(src.find("@PROTOCOL@"), std::string::npos) + expect(ne(src.find("@PROTOCOL@"), std::string::npos)) << "the bundled module no longer carries the substitution point"; const std::string hardcoded = "mcpp:protocol=" + std::to_string(pp::kProtocolVersion); - EXPECT_EQ(src.find(hardcoded), std::string::npos) + expect(eq(src.find(hardcoded), std::string::npos)) << "the protocol number is spelled into the bundled module; " "substitute @PROTOCOL@ instead so the two cannot diverge"; -} +}}; -TEST(SubsystemContracts, TheModuleDeclarationIsAlsoSubstituted) { +UTest the_module_declaration_is_also_substituted{"SubsystemContracts/TheModuleDeclarationIsAlsoSubstituted", [] { // `export module mcpp;` inside a string literal would be read by mcpp's // own regex scanner as this file exporting a second module. The engine // writes `@MODULE@` and substitutes it when it emits the source. const std::string src{mcpp::build::kMcppModuleSource}; - EXPECT_NE(src.find("@MODULE@"), std::string::npos); - EXPECT_EQ(src.find("export module mcpp;"), std::string::npos) + expect(ne(src.find("@MODULE@"), std::string::npos)); + expect(eq(src.find("export module mcpp;"), std::string::npos)) << "a literal module declaration here makes the scanner believe this " "translation unit exports it"; -} +}}; -TEST(SubsystemContracts, TheBinaryVersionMatchesTheRootManifest) { +UTest the_binary_version_matches_the_root_manifest{"SubsystemContracts/TheBinaryVersionMatchesTheRootManifest", [] { // Two files, one fact. `.github/tools/check_version_pins.sh` enforces this // as text before anything is built; this asserts it on the value the // COMPILED binary carries, which is what `--version`, the BMI fingerprint @@ -73,11 +75,11 @@ TEST(SubsystemContracts, TheBinaryVersionMatchesTheRootManifest) { fs::path root = fs::current_path(); for (int i = 0; i < 6 && !fs::exists(root / "mcpp.toml"); ++i) root = root.parent_path(); - ASSERT_TRUE(fs::exists(root / "mcpp.toml")) + expect(fatal(fs::exists(root / "mcpp.toml"))) << "could not find the root manifest from " << fs::current_path(); std::ifstream in(root / "mcpp.toml"); - ASSERT_TRUE(in.is_open()); + expect(fatal(in.is_open())); std::string line, fromToml; while (std::getline(in, line)) { if (!line.starts_with("version")) continue; @@ -87,6 +89,6 @@ TEST(SubsystemContracts, TheBinaryVersionMatchesTheRootManifest) { fromToml = line.substr(a + 1, b - a - 1); break; } - ASSERT_FALSE(fromToml.empty()) << "no [package] version in the root manifest"; - EXPECT_EQ(fromToml, std::string(mcpp::MCPP_VERSION)); -} + if (auto&& failed = expect(fatal(!fromToml.empty())); !failed) failed << "no [package] version in the root manifest"; + expect(eq(fromToml, std::string(mcpp::MCPP_VERSION))); +}}; diff --git a/tests/unit/test_symbol_provision.cpp b/tests/unit/test_symbol_provision.cpp index f31f44832..e0d09ead7 100644 --- a/tests/unit/test_symbol_provision.cpp +++ b/tests/unit/test_symbol_provision.cpp @@ -11,12 +11,14 @@ // arrangement, where a shared dependency's static dependency legitimately // lands in the consumer's executable with exactly one copy in the process. -#include import std; +import mcpplibs.mtest; import mcpp.build.symbol_provision; import mcpp.runtime.elf; +using namespace mcpplibs::mtest; + namespace sp = mcpp::build::symbol_provision; namespace elf = mcpp::runtime::elf; @@ -47,27 +49,27 @@ std::vector names(const std::vector& exports) { // ── stage one ────────────────────────────────────────────────────────────── -TEST(SymbolProvision, ADefinedFunctionIsAlwaysAnExport) { +UTest a_defined_function_is_always_an_export{"SymbolProvision/ADefinedFunctionIsAlwaysAnExport", [] { auto s = image(); s.defined.push_back(func("inflate")); // Even at an address that carries a copy relocation: a copy relocation // moves DATA, so a function sharing that address is not one. s.copyRelocations.insert(0x1000); auto exports = sp::exported_definitions(s); - ASSERT_TRUE(exports.has_value()); - EXPECT_EQ(names(*exports), std::vector{"inflate"}); -} + expect(fatal(exports.has_value())); + expect(eq(names(*exports), std::vector{"inflate"})); +}}; -TEST(SymbolProvision, CopyRelocatedDataIsNotAnExport) { +UTest copy_relocated_data_is_not_an_export{"SymbolProvision/CopyRelocatedDataIsNotAnExport", [] { auto s = image(); s.defined.push_back(object("stdout", 0xb1d888)); s.copyRelocations.insert(0xb1d888); auto exports = sp::exported_definitions(s); - ASSERT_TRUE(exports.has_value()); - EXPECT_TRUE(exports->empty()); -} + expect(fatal(exports.has_value())); + expect(exports->empty()); +}}; -TEST(SymbolProvision, CopyRelocationsMatchByAddressNotByName) { +UTest copy_relocations_match_by_address_not_by_name{"SymbolProvision/CopyRelocationsMatchByAddressNotByName", [] { // MEASURED, on every mcpp binary: glibc's `environ` is a WEAK alias of // `__environ` at one address, and only `__environ` appears in `.rela.dyn`. // A name-keyed filter reports `environ` as a hijacked symbol in every @@ -77,35 +79,34 @@ TEST(SymbolProvision, CopyRelocationsMatchByAddressNotByName) { s.defined.push_back(object("environ", 0xb1d840)); // same address s.copyRelocations.insert(0xb1d840); // only one entry auto exports = sp::exported_definitions(s); - ASSERT_TRUE(exports.has_value()); - EXPECT_TRUE(exports->empty()) << "environ must not be reported"; -} + expect(fatal(exports.has_value())); + expect(exports->empty()) << "environ must not be reported"; +}}; -TEST(SymbolProvision, AnUninitialisedDataSymbolWithNoCopyRelocationIsAnExport) { +UTest an_uninitialised_data_symbol_with_no_copy_relocation_is_an_export{"SymbolProvision/AnUninitialisedDataSymbolWithNoCopyRelocationIsAnExport", [] { // /usr/bin/ls's `obstack_alloc_failed_handler` — a function-pointer // variable that gnulib defines and glibc also defines. It lives in .bss // like a copy relocation would, and it is NOT one. auto s = image(); s.defined.push_back(object("obstack_alloc_failed_handler", 0x2000)); auto exports = sp::exported_definitions(s); - ASSERT_TRUE(exports.has_value()); - EXPECT_EQ(names(*exports), - std::vector{"obstack_alloc_failed_handler"}); -} + expect(fatal(exports.has_value())); + expect(eq(names(*exports), std::vector{"obstack_alloc_failed_handler"})); +}}; -TEST(SymbolProvision, AnUnknownMachineDeclinesRatherThanGuessing) { +UTest an_unknown_machine_declines_rather_than_guessing{"SymbolProvision/AnUnknownMachineDeclinesRatherThanGuessing", [] { // Without the machine's COPY relocation type every data symbol looks like // an export. "Could not evaluate" must not be spelled the same way as // "evaluated and clean". auto s = image(); s.copyRelocationsKnown = false; s.defined.push_back(object("stdout", 0x10)); - EXPECT_FALSE(sp::exported_definitions(s).has_value()); -} + expect(!(sp::exported_definitions(s).has_value())); +}}; // ── stage two ────────────────────────────────────────────────────────────── -TEST(SymbolProvision, AnExportWithNoSecondProviderIsNotAConflict) { +UTest an_export_with_no_second_provider_is_not_a_conflict{"SymbolProvision/AnExportWithNoSecondProviderIsNotAConflict", [] { // mcpp's OWN arrangement. A `kind = "shared"` dependency's link unit // takes only its own objects, so its static dependency lands in the // consumer's executable and the shared library binds back to it. One copy @@ -115,10 +116,10 @@ TEST(SymbolProvision, AnExportWithNoSecondProviderIsNotAConflict) { std::vector closure{ sp::Provider{"/lib/libc.so.6", {"printf", "malloc"}}, }; - EXPECT_TRUE(sp::conflicting_exports(exports, closure).empty()); -} + expect(sp::conflicting_exports(exports, closure).empty()); +}}; -TEST(SymbolProvision, AnExportWithASecondProviderIsAConflictAndNamesIt) { +UTest an_export_with_a_second_provider_is_a_conflict_and_names_it{"SymbolProvision/AnExportWithASecondProviderIsAConflictAndNamesIt", [] { std::vector exports{ sp::Export{"inflate", true}, sp::Export{"deflate", true} }; std::vector closure{ @@ -126,13 +127,13 @@ TEST(SymbolProvision, AnExportWithASecondProviderIsAConflictAndNamesIt) { sp::Provider{"/lib/libc.so.6", {"printf"}}, }; auto conflicts = sp::conflicting_exports(exports, closure); - ASSERT_EQ(conflicts.size(), 2u); - EXPECT_EQ(conflicts[0].name, "inflate"); - ASSERT_EQ(conflicts[0].alsoProvidedBy.size(), 1u); - EXPECT_EQ(conflicts[0].alsoProvidedBy[0], "/pkg/lib/libz.so.1"); -} + expect(fatal(eq(conflicts.size(), 2u))); + expect(eq(conflicts[0].name, "inflate")); + expect(fatal(eq(conflicts[0].alsoProvidedBy.size(), 1u))); + expect(eq(conflicts[0].alsoProvidedBy[0], "/pkg/lib/libz.so.1")); +}}; -TEST(SymbolProvision, TheReportNamesEveryProviderAndCapsTheSymbolList) { +UTest the_report_names_every_provider_and_caps_the_symbol_list{"SymbolProvision/TheReportNamesEveryProviderAndCapsTheSymbolList", [] { sp::Report report; report.status = sp::Status::Conflict; report.total = 217; @@ -148,63 +149,62 @@ TEST(SymbolProvision, TheReportNamesEveryProviderAndCapsTheSymbolList) { report.exported = report.conflicts.size(); auto text = report.explain("consumer"); - EXPECT_NE(text.find("20 symbols"), std::string::npos); - EXPECT_NE(text.find("and 14 more"), std::string::npos); - EXPECT_NE(text.find("/pkg/lib/libz.so.1"), std::string::npos); + expect(ne(text.find("20 symbols"), std::string::npos)); + expect(ne(text.find("and 14 more"), std::string::npos)); + expect(ne(text.find("/pkg/lib/libz.so.1"), std::string::npos)); // The three ways out are the point of the message, and their ORDER is // load-bearing: switching the form removes this finding while leaving two // copies loaded unless the SONAMEs also match, so it must not be first. auto stop = text.find("stop one side"); auto soname = text.find("SONAME"); auto form = text.find("dependency_linkage"); - ASSERT_NE(stop, std::string::npos); - ASSERT_NE(soname, std::string::npos); - ASSERT_NE(form, std::string::npos); - EXPECT_LT(stop, soname); - EXPECT_LT(soname, form); -} + expect(fatal(ne(stop, std::string::npos))); + expect(fatal(ne(soname, std::string::npos))); + expect(fatal(ne(form, std::string::npos))); + expect(lt(stop, soname)); + expect(lt(soname, form)); +}}; -TEST(SymbolProvision, OnlyAConflictIsActionable) { - EXPECT_FALSE(sp::not_applicable("static").actionable()); - EXPECT_FALSE(sp::not_evaluated("unknown machine").actionable()); +UTest only_a_conflict_is_actionable{"SymbolProvision/OnlyAConflictIsActionable", [] { + expect(!(sp::not_applicable("static").actionable())); + expect(!(sp::not_evaluated("unknown machine").actionable())); sp::Report clean; clean.status = sp::Status::Clean; - EXPECT_FALSE(clean.actionable()); - EXPECT_TRUE(clean.explain("x").empty()); -} + expect(!clean.actionable()); + expect(clean.explain("x").empty()); +}}; -TEST(SymbolProvision, ANonAnswerCarriesItsReason) { +UTest a_non_answer_carries_its_reason{"SymbolProvision/ANonAnswerCarriesItsReason", [] { // "Not checked" and "checked and clean" must never render the same. auto na = sp::not_applicable("statically linked"); - EXPECT_EQ(na.status, sp::Status::NotApplicable); - EXPECT_EQ(na.reason, "statically linked"); - EXPECT_EQ(sp::to_string(sp::Status::NotApplicable), "not-applicable"); - EXPECT_EQ(sp::to_string(sp::Status::NotEvaluated), "not-evaluated"); - EXPECT_NE(sp::to_string(sp::Status::Clean), - sp::to_string(sp::Status::NotApplicable)); -} + expect(eq(na.status, sp::Status::NotApplicable)); + expect(eq(na.reason, "statically linked")); + expect(eq(sp::to_string(sp::Status::NotApplicable), "not-applicable")); + expect(eq(sp::to_string(sp::Status::NotEvaluated), "not-evaluated")); + expect(ne(sp::to_string(sp::Status::Clean), sp::to_string(sp::Status::NotApplicable))); +}}; // ── the precondition ─────────────────────────────────────────────────────── -TEST(SymbolProvision, AnAuthorRequestedExportSurfaceVoidsThePredicate) { +UTest an_author_requested_export_surface_voids_the_predicate{"SymbolProvision/AnAuthorRequestedExportSurfaceVoidsThePredicate", [] { // /usr/bin/bash exports 2339 symbols on purpose, for loadable builtins. // Detecting exactly the thing the author asked for is not a finding. - EXPECT_TRUE(sp::export_dynamic_requested( - std::vector{"-O2", "-rdynamic"})); - EXPECT_TRUE(sp::export_dynamic_requested( - std::vector{"-Wl,--export-dynamic"})); - EXPECT_TRUE(sp::export_dynamic_requested(std::vector{"-Wl,-E"})); - EXPECT_TRUE(sp::export_dynamic_requested( - std::vector{"-Wl,--dynamic-list=syms.txt"})); - EXPECT_TRUE(sp::export_dynamic_requested( - std::vector{"-Wl,--export-dynamic-symbol=foo"})); -} + expect(bool(sp::export_dynamic_requested( + std::vector{"-O2", "-rdynamic"}))); + expect(bool(sp::export_dynamic_requested( + std::vector{"-Wl,--export-dynamic"}))); + expect(bool(sp::export_dynamic_requested(std::vector{"-Wl,-E"}))); + expect(bool(sp::export_dynamic_requested( + std::vector{"-Wl,--dynamic-list=syms.txt"}))); + expect(bool(sp::export_dynamic_requested( + std::vector{"-Wl,--export-dynamic-symbol=foo"}))); +}}; -TEST(SymbolProvision, OrdinaryLinkFlagsDoNotVoidThePredicate) { +UTest ordinary_link_flags_do_not_void_the_predicate{"SymbolProvision/OrdinaryLinkFlagsDoNotVoidThePredicate", [] { // A substring sweep would fire on any of these. - EXPECT_FALSE(sp::export_dynamic_requested(std::vector{ + expect(!sp::export_dynamic_requested(std::vector{ "-O2", "-Wl,-rpath,$ORIGIN", "-lz", "-Wl,--as-needed", "-Wl,--enable-new-dtags", "-static-libstdc++", "-shared"})); -} +}}; // ── vague linkage is not a second provider ───────────────────────────────── // @@ -219,21 +219,21 @@ TEST(SymbolProvision, OrdinaryLinkFlagsDoNotVoidThePredicate) { // island's own `extern "C"` entry points, which libsycl does not define. A // check that could not tell binding from name reported all of them. -TEST(SymbolProvision, AWeakDefinitionIsCarriedThroughAsWeak) { +UTest a_weak_definition_is_carried_through_as_weak{"SymbolProvision/AWeakDefinitionIsCarriedThroughAsWeak", [] { auto s = image(); auto weak = func("_ZN4sycl3_V15queueD2Ev"); weak.isWeak = true; s.defined.push_back(weak); s.defined.push_back(func("saxpy_device")); auto exports = sp::exported_definitions(s); - ASSERT_TRUE(exports.has_value()); - ASSERT_EQ(exports->size(), 2u); + expect(fatal(exports.has_value())); + expect(fatal(eq(exports->size(), 2u))); // Sorted by name: the mangled one first. - EXPECT_TRUE((*exports)[0].isWeak); - EXPECT_FALSE((*exports)[1].isWeak); -} + expect(bool((*exports)[0].isWeak)); + expect(!((*exports)[1].isWeak)); +}}; -TEST(SymbolProvision, AConflictRemembersWhetherItsDefinitionIsWeak) { +UTest a_conflict_remembers_whether_its_definition_is_weak{"SymbolProvision/AConflictRemembersWhetherItsDefinitionIsWeak", [] { std::vector exports{ { .name = "_ZN4sycl3_V15queueD2Ev", .isFunc = true, .isWeak = true }, { .name = "inflate", .isFunc = true, .isWeak = false }, @@ -243,29 +243,29 @@ TEST(SymbolProvision, AConflictRemembersWhetherItsDefinitionIsWeak) { .defines = {"_ZN4sycl3_V15queueD2Ev", "inflate"} }, }; auto conflicts = sp::conflicting_exports(exports, closure); - ASSERT_EQ(conflicts.size(), 2u); + expect(fatal(eq(conflicts.size(), 2u))); // Both are shared; only the binding separates them, and the caller is what // decides which one is a finding. Asserted here rather than in the caller // so the DATA carries the distinction even if a future caller forgets it. - EXPECT_TRUE(conflicts[0].isWeak); - EXPECT_FALSE(conflicts[1].isWeak); -} + expect(bool(conflicts[0].isWeak)); + expect(!(conflicts[1].isWeak)); +}}; // #646 F3. GCC emits the static data of an inline entity with STB_GNU_UNIQUE // (10) so that the loader keeps one copy across RTLD_LOCAL. It is vague linkage // exactly as STB_WEAK is; reading only the latter reported seven libstdc++ // objects as a program displacing the library. -TEST(SymbolProvision, TheUniqueBindingIsVagueLinkageAsTheWeakOneIs) { - EXPECT_TRUE(elf::is_vague_linkage_binding(2)); // STB_WEAK - EXPECT_TRUE(elf::is_vague_linkage_binding(10)); // STB_GNU_UNIQUE - EXPECT_FALSE(elf::is_vague_linkage_binding(1)); // STB_GLOBAL - EXPECT_FALSE(elf::is_vague_linkage_binding(0)); // STB_LOCAL -} +UTest the_unique_binding_is_vague_linkage_as_the_weak_one_is{"SymbolProvision/TheUniqueBindingIsVagueLinkageAsTheWeakOneIs", [] { + expect(elf::is_vague_linkage_binding(2)); // STB_WEAK + expect(elf::is_vague_linkage_binding(10)); // STB_GNU_UNIQUE + expect(!elf::is_vague_linkage_binding(1)); // STB_GLOBAL + expect(!elf::is_vague_linkage_binding(0)); // STB_LOCAL +}}; // #646 F3. The std module's initialiser is linked into every C++ image that // imports `std`, from one object. A provider that defines a name only through // an object this build linked into both images is removed from that conflict. -TEST(SymbolProvision, ADefinitionBothImagesTakeFromOneObjectIsNotAConflict) { +UTest a_definition_both_images_take_from_one_object_is_not_a_conflict{"SymbolProvision/ADefinitionBothImagesTakeFromOneObjectIsNotAConflict", [] { std::vector conflicts{ { .name = "_ZGIW3std", .isFunc = true, .alsoProvidedBy = {"bin/liblib.so"} }, @@ -275,21 +275,20 @@ TEST(SymbolProvision, ADefinitionBothImagesTakeFromOneObjectIsNotAConflict) { const std::map> shared{ { "bin/liblib.so", {"_ZGIW3std"} }, }; - EXPECT_EQ(sp::drop_shared_plan_definitions(conflicts, shared), 1u); - ASSERT_EQ(conflicts.size(), 1u); - EXPECT_EQ(conflicts[0].name, "inflate"); + expect(eq(sp::drop_shared_plan_definitions(conflicts, shared), 1u)); + expect(fatal(eq(conflicts.size(), 1u))); + expect(eq(conflicts[0].name, "inflate")); // The library still defines `inflate` from its OWN object, so it stays a // provider: attribution is by object, never by the library as a whole. - EXPECT_EQ(conflicts[0].alsoProvidedBy, - (std::vector{"bin/liblib.so", "/usr/lib/libz.so.1"})); -} + expect(eq(conflicts[0].alsoProvidedBy, (std::vector{"bin/liblib.so", "/usr/lib/libz.so.1"}))); +}}; // The rule is provenance, not a name pattern: an initialiser-shaped name that // no shared object defines is still a finding. -TEST(SymbolProvision, AnInitialiserShapedNameWithoutSharedProvenanceIsStillReported) { +UTest an_initialiser_shaped_name_without_shared_provenance_is_still_reported{"SymbolProvision/AnInitialiserShapedNameWithoutSharedProvenanceIsStillReported", [] { std::vector conflicts{ { .name = "_ZGIW5other", .isFunc = true, .alsoProvidedBy = {"bin/libother.so"} }, }; - EXPECT_EQ(sp::drop_shared_plan_definitions(conflicts, {}), 0u); - EXPECT_EQ(conflicts.size(), 1u); -} + expect(eq(sp::drop_shared_plan_definitions(conflicts, {}), 0u)); + expect(eq(conflicts.size(), 1u)); +}}; diff --git a/tests/unit/test_sysroot_ownership.cpp b/tests/unit/test_sysroot_ownership.cpp index 56e1f6be3..dd1a40cb0 100644 --- a/tests/unit/test_sysroot_ownership.cpp +++ b/tests/unit/test_sysroot_ownership.cpp @@ -10,11 +10,13 @@ // These assertions pin the axis that replaced it. Nothing here touches the // filesystem, deliberately: the point is that existence is not consulted. -#include import std; +import mcpplibs.mtest; import mcpp.fallback.probe_sysroot; +using namespace mcpplibs::mtest; + namespace fb = mcpp::fallback; namespace { @@ -22,66 +24,66 @@ namespace { const std::filesystem::path kRegistry = "/home/u/.mcpp/registry"; const std::filesystem::path kProject = "/home/u/work/thisrepo"; -TEST(SysrootOwnership, PayloadSysrootIsOwned) { - EXPECT_FALSE(fb::sysroot_is_foreign( +UTest payload_sysroot_is_owned{"SysrootOwnership/PayloadSysrootIsOwned", [] { + expect(!fb::sysroot_is_foreign( kRegistry / "subos" / "default", kRegistry, kProject)); -} +}}; -TEST(SysrootOwnership, ProjectLocalSysrootIsOwned) { - EXPECT_FALSE(fb::sysroot_is_foreign( +UTest project_local_sysroot_is_owned{"SysrootOwnership/ProjectLocalSysrootIsOwned", [] { + expect(!fb::sysroot_is_foreign( kProject / ".xlings" / "subos" / "default", kRegistry, kProject)); -} +}}; // The case that motivated the predicate: a path that exists, is spelled // exactly like a legitimate one, and belongs to someone else. -TEST(SysrootOwnership, AnotherCheckoutIsForeign) { - EXPECT_TRUE(fb::sysroot_is_foreign( +UTest another_checkout_is_foreign{"SysrootOwnership/AnotherCheckoutIsForeign", [] { + expect(fb::sysroot_is_foreign( "/home/u/work/otherrepo/.xlings/subos/default", kRegistry, kProject)); -} +}}; -TEST(SysrootOwnership, AnotherHomeIsForeign) { - EXPECT_TRUE(fb::sysroot_is_foreign( +UTest another_home_is_foreign{"SysrootOwnership/AnotherHomeIsForeign", [] { + expect(fb::sysroot_is_foreign( "/home/other/.mcpp/registry/subos/default", kRegistry, kProject)); -} +}}; // Both anchors are load-bearing. Registry alone would condemn every // project-local tree; project alone would condemn every payload sysroot. -TEST(SysrootOwnership, EitherAnchorSuffices) { - EXPECT_FALSE(fb::sysroot_is_foreign( +UTest either_anchor_suffices{"SysrootOwnership/EitherAnchorSuffices", [] { + expect(!fb::sysroot_is_foreign( kRegistry / "subos" / "default", kRegistry, {})); - EXPECT_FALSE(fb::sysroot_is_foreign( + expect(!fb::sysroot_is_foreign( kProject / ".xlings" / "subos" / "default", {}, kProject)); -} +}}; // No sysroot is not a foreign sysroot. gcc payloads on this machine report an // empty `-print-sysroot`, so this is the ordinary case, not an edge one. -TEST(SysrootOwnership, EmptyIsNotForeign) { - EXPECT_FALSE(fb::sysroot_is_foreign({}, kRegistry, kProject)); -} +UTest empty_is_not_foreign{"SysrootOwnership/EmptyIsNotForeign", [] { + expect(!fb::sysroot_is_foreign({}, kRegistry, kProject)); +}}; // A prefix that merely shares characters is not containment. -TEST(SysrootOwnership, SiblingPrefixIsNotContainment) { - EXPECT_TRUE(fb::sysroot_is_foreign( +UTest sibling_prefix_is_not_containment{"SysrootOwnership/SiblingPrefixIsNotContainment", [] { + expect(fb::sysroot_is_foreign( "/home/u/work/thisrepo-backup/.xlings/subos/default", kRegistry, kProject)); -} +}}; // A directory whose NAME begins with two dots is not an escape. The first // version compared the relative path's text (`rfind("..", 0)`), which called // `/home/u/.mcpp/registry/..cache` an escape from the registry -- and did not // compile at all on Windows, where `native()` is a wstring. Containment is a // question about path components, so it is asked of components. -TEST(SysrootOwnership, DotDotPrefixedNameIsNotAnEscape) { - EXPECT_FALSE(fb::sysroot_is_foreign( +UTest dot_dot_prefixed_name_is_not_an_escape{"SysrootOwnership/DotDotPrefixedNameIsNotAnEscape", [] { + expect(!fb::sysroot_is_foreign( kRegistry / "..cache" / "subos" / "default", kRegistry, kProject)); -} - -TEST(SysrootOwnership, PathIsUnderIsDirectlyTestable) { - EXPECT_TRUE(fb::path_is_under(kRegistry / "a" / "b", kRegistry)); - EXPECT_TRUE(fb::path_is_under(kRegistry, kRegistry)); - EXPECT_FALSE(fb::path_is_under(kRegistry.parent_path(), kRegistry)); - EXPECT_FALSE(fb::path_is_under(kRegistry, {})); - EXPECT_FALSE(fb::path_is_under({}, kRegistry)); -} +}}; + +UTest path_is_under_is_directly_testable{"SysrootOwnership/PathIsUnderIsDirectlyTestable", [] { + expect(fb::path_is_under(kRegistry / "a" / "b", kRegistry)); + expect(fb::path_is_under(kRegistry, kRegistry)); + expect(!(fb::path_is_under(kRegistry.parent_path(), kRegistry))); + expect(!fb::path_is_under(kRegistry, {})); + expect(!fb::path_is_under({}, kRegistry)); +}}; } // namespace diff --git a/tests/unit/test_target_base.cpp b/tests/unit/test_target_base.cpp index c2ed0bc1b..5c82a336f 100644 --- a/tests/unit/test_target_base.cpp +++ b/tests/unit/test_target_base.cpp @@ -1,6 +1,6 @@ -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.base; import mcpp.toolchain.base_probe; import mcpp.toolchain.linkmodel; @@ -11,6 +11,8 @@ import mcpp.toolchain.apple_sdk; import mcpp.invocation; import mcpp.build.command_line; +using namespace mcpplibs::mtest; + // The target base (design 2026-10-23, "target base and execution host"): the // string grammar of `sysroot`, which spellings each row takes, the // measurement tables, and what the link model renders for a stated base. @@ -49,148 +51,147 @@ bool has(const std::vector& v, std::string_view s) { } // namespace -TEST(TargetBaseGrammar, EveryForm) { +UTest every_form{"TargetBaseGrammar/EveryForm", [] { auto e = tb::parse_text(""); - ASSERT_TRUE(e); - EXPECT_EQ(e->form, tb::Form::Empty); + expect(fatal(bool(e))); + expect(eq(e->form, tb::Form::Empty)); auto p = tb::parse_text("path:/opt/acme//sysroot"); - ASSERT_TRUE(p); - EXPECT_EQ(p->form, tb::Form::Path); - EXPECT_EQ(p->text, "path:/opt/acme/sysroot"); + expect(fatal(bool(p))); + expect(eq(p->form, tb::Form::Path)); + expect(eq(p->text, "path:/opt/acme/sysroot")); auto k = tb::parse_text("acme:sysroot-rhel8@1.0"); - ASSERT_TRUE(k); - EXPECT_EQ(k->form, tb::Form::Package); + expect(fatal(bool(k))); + expect(eq(k->form, tb::Form::Package)); auto l = tb::parse_text("glibc@system"); - ASSERT_TRUE(l); - EXPECT_EQ(l->form, tb::Form::Locator); - EXPECT_EQ(l->family, "glibc"); - EXPECT_EQ(l->version, "system"); + expect(fatal(bool(l))); + expect(eq(l->form, tb::Form::Locator)); + expect(eq(l->family, "glibc")); + expect(eq(l->version, "system")); // A versioned C library without a namespace is the ecosystem's package // (SPEC-006 §2.3); a versioned SDK is a locator with a version. auto g = tb::parse_text("glibc@2.39"); - ASSERT_TRUE(g); - EXPECT_EQ(g->form, tb::Form::Package); - EXPECT_EQ(g->text, "xim:glibc@2.39"); + expect(fatal(bool(g))); + expect(eq(g->form, tb::Form::Package)); + expect(eq(g->text, "xim:glibc@2.39")); auto i = tb::parse_text("iphoneos@18.5"); - ASSERT_TRUE(i); - EXPECT_EQ(i->form, tb::Form::Locator); - EXPECT_EQ(i->version, "18.5"); -} - -TEST(TargetBaseGrammar, RefusesWhatNamesNothing) { - EXPECT_FALSE(tb::parse_text("sysroot")); - EXPECT_FALSE(tb::parse_text("path:")); - EXPECT_FALSE(tb::parse_text("bogus@1.0")); + expect(fatal(bool(i))); + expect(eq(i->form, tb::Form::Locator)); + expect(eq(i->version, "18.5")); +}}; + +UTest refuses_what_names_nothing{"TargetBaseGrammar/RefusesWhatNamesNothing", [] { + expect(!tb::parse_text("sysroot")); + expect(!tb::parse_text("path:")); + expect(!tb::parse_text("bogus@1.0")); // An Apple SDK has no package; `@system` takes no namespace. - EXPECT_FALSE(tb::parse_text("xim:iphoneos@18.5")); - EXPECT_FALSE(tb::parse_text("xim:glibc@system")); -} + expect(!tb::parse_text("xim:iphoneos@18.5")); + expect(!tb::parse_text("xim:glibc@system")); +}}; -TEST(TargetBaseRows, EachRowTakesItsOwnForms) { +UTest each_row_takes_its_own_forms{"TargetBaseRows/EachRowTakesItsOwnForms", [] { auto linux_gnu = *parse("x86_64-linux-gnu"); - EXPECT_FALSE(tb::refusal(linux_gnu, *tb::parse_text("glibc@system"))); - EXPECT_FALSE(tb::refusal(linux_gnu, *tb::parse_text("path:/x"))); - EXPECT_FALSE(tb::refusal(linux_gnu, *tb::parse_text("xim:glibc@2.39"))); + expect(!(tb::refusal(linux_gnu, *tb::parse_text("glibc@system")))); + expect(!(tb::refusal(linux_gnu, *tb::parse_text("path:/x")))); + expect(!(tb::refusal(linux_gnu, *tb::parse_text("xim:glibc@2.39")))); // A family names a platform, so it must agree with the row. - EXPECT_TRUE(tb::refusal(linux_gnu, *tb::parse_text("musl@system"))); - EXPECT_TRUE(tb::refusal(linux_gnu, *tb::parse_text("iphoneos@system"))); + expect(bool(tb::refusal(linux_gnu, *tb::parse_text("musl@system")))); + expect(bool(tb::refusal(linux_gnu, *tb::parse_text("iphoneos@system")))); auto sim = *parse("aarch64-ios-sim"); - EXPECT_FALSE(tb::refusal(sim, *tb::parse_text("iphonesimulator@system"))); - EXPECT_FALSE(tb::refusal(sim, *tb::parse_text("path:/sdk"))); - EXPECT_TRUE(tb::refusal(sim, *tb::parse_text("iphoneos@system"))); + expect(!(tb::refusal(sim, *tb::parse_text("iphonesimulator@system")))); + expect(!(tb::refusal(sim, *tb::parse_text("path:/sdk")))); + expect(bool(tb::refusal(sim, *tb::parse_text("iphoneos@system")))); // The spellings the row took before it took a locator keep their meaning. - EXPECT_FALSE(tb::refusal(sim, *tb::parse_text(""))); + expect(!(tb::refusal(sim, *tb::parse_text("")))); auto msvc = *parse("x86_64-windows-msvc"); - EXPECT_FALSE(tb::refusal(msvc, *tb::parse_text("msvc@system"))); - EXPECT_FALSE(tb::refusal(msvc, *tb::parse_text("xim:msvc@14.44"))); - EXPECT_TRUE(tb::refusal(msvc, *tb::parse_text("acme:vs@1"))); + expect(!(tb::refusal(msvc, *tb::parse_text("msvc@system")))); + expect(!(tb::refusal(msvc, *tb::parse_text("xim:msvc@14.44")))); + expect(bool(tb::refusal(msvc, *tb::parse_text("acme:vs@1")))); // The MSVC spelling on a MinGW row names another platform's toolset. - EXPECT_TRUE(tb::refusal(*parse("x86_64-windows-gnu"), *tb::parse_text("msvc@14.44.35207"))); + expect(bool(tb::refusal(*parse("x86_64-windows-gnu"), *tb::parse_text("msvc@14.44.35207")))); // An SDK toolchain's row carries its own system: no new form. auto android = *parse("aarch64-linux-android"); - EXPECT_TRUE(tb::refusal(android, *tb::parse_text("path:/ndk"))); - EXPECT_EQ(tb::row_base(android).address, tb::Address::DriverInternal); + expect(bool(tb::refusal(android, *tb::parse_text("path:/ndk")))); + expect(eq(tb::row_base(android).address, tb::Address::DriverInternal)); - EXPECT_EQ(tb::row_base(*parse("riscv64-none-elf")).address, tb::Address::Multilib); - EXPECT_EQ(tb::row_base(*parse("aarch64-macos")).address, tb::Address::AppleIsysroot); -} + expect(eq(tb::row_base(*parse("riscv64-none-elf")).address, tb::Address::Multilib)); + expect(eq(tb::row_base(*parse("aarch64-macos")).address, tb::Address::AppleIsysroot)); +}}; -TEST(TargetBaseRows, PathSlashIsTheHostLocator) { +UTest path_slash_is_the_host_locator{"TargetBaseRows/PathSlashIsTheHostLocator", [] { auto s = tb::normalise(*parse("x86_64-linux-gnu"), *tb::parse_text("path:/")); - EXPECT_EQ(s.form, tb::Form::Locator); - EXPECT_EQ(s.text, "glibc@system"); + expect(eq(s.form, tb::Form::Locator)); + expect(eq(s.text, "glibc@system")); auto m = tb::normalise(*parse("x86_64-linux-musl"), *tb::parse_text("path:/")); - EXPECT_EQ(m.text, "musl@system"); -} + expect(eq(m.text, "musl@system")); +}}; -TEST(TargetBaseMeasure, ImplementationFromThePreprocessor) { +UTest implementation_from_the_preprocessor{"TargetBaseMeasure/ImplementationFromThePreprocessor", [] { auto glibc = tb::classify_macros(tb::parse_defines( "#define __GLIBC__ 2\n#define __GLIBC_MINOR__ 31\n#define FOO(x) x\n")); - ASSERT_TRUE(glibc); - EXPECT_EQ(glibc->impl, "glibc"); - EXPECT_EQ(glibc->version, "2.31"); + expect(fatal(bool(glibc))); + expect(eq(glibc->impl, "glibc")); + expect(eq(glibc->version, "2.31")); auto pico = tb::classify_macros(tb::parse_defines( "#define __PICOLIBC__ 1\n#define __PICOLIBC_VERSION__ \"1.8.12\"\n")); - ASSERT_TRUE(pico); - EXPECT_EQ(pico->version, "1.8.12"); + expect(fatal(bool(pico))); + expect(eq(pico->version, "1.8.12")); auto bionic = tb::classify_macros(tb::parse_defines( "#define __BIONIC__ 1\n#define __ANDROID_API__ 24\n")); - ASSERT_TRUE(bionic); - EXPECT_EQ(bionic->factName, "android.api"); - - EXPECT_FALSE(tb::classify_macros(tb::parse_defines("#define __linux__ 1\n"))); -} - -TEST(TargetBaseMeasure, VersionsCompareNumerically) { - EXPECT_LT(tb::compare_versions("2.9", "2.31"), 0); - EXPECT_GT(tb::compare_versions("2.38", "2.31"), 0); - EXPECT_EQ(tb::compare_versions("2.31", "2.31.0"), 0); - EXPECT_EQ(tb::highest_glibc({"GLIBC_2.2.5", "GLIBC_2.38", "GLIBC_PRIVATE", "GLIBC_2.34"}), - "2.38"); -} - -TEST(TargetBaseMeasure, MultiarchTuplesAreDebiansTable) { - EXPECT_EQ(tb::multiarch_tuple(*parse("x86_64-linux-gnu")), "x86_64-linux-gnu"); - EXPECT_EQ(tb::multiarch_tuple(*parse("aarch64-linux-gnu")), "aarch64-linux-gnu"); - EXPECT_EQ(tb::multiarch_tuple(*parse("x86_64-linux-musl")), "x86_64-linux-musl"); -} - -TEST(TargetBaseMeasure, PayloadLayoutIsNotASysroot) { + expect(fatal(bool(bionic))); + expect(eq(bionic->factName, "android.api")); + + expect(!(tb::classify_macros(tb::parse_defines("#define __linux__ 1\n")))); +}}; + +UTest versions_compare_numerically{"TargetBaseMeasure/VersionsCompareNumerically", [] { + expect(lt(tb::compare_versions("2.9", "2.31"), 0)); + expect(gt(tb::compare_versions("2.38", "2.31"), 0)); + expect(eq(tb::compare_versions("2.31", "2.31.0"), 0)); + expect(eq(tb::highest_glibc({"GLIBC_2.2.5", "GLIBC_2.38", "GLIBC_PRIVATE", "GLIBC_2.34"}), "2.38")); +}}; + +UTest multiarch_tuples_are_debians_table{"TargetBaseMeasure/MultiarchTuplesAreDebiansTable", [] { + expect(eq(tb::multiarch_tuple(*parse("x86_64-linux-gnu")), "x86_64-linux-gnu")); + expect(eq(tb::multiarch_tuple(*parse("aarch64-linux-gnu")), "aarch64-linux-gnu")); + expect(eq(tb::multiarch_tuple(*parse("x86_64-linux-musl")), "x86_64-linux-musl")); +}}; + +UTest payload_layout_is_not_a_sysroot{"TargetBaseMeasure/PayloadLayoutIsNotASysroot", [] { Tmp t; touch(t.path / "pkg" / "include" / "features.h"); touch(t.path / "pkg" / "lib64" / "libc.so.6"); - EXPECT_TRUE(tb::is_payload_layout(t.path / "pkg")); + expect(tb::is_payload_layout(t.path / "pkg")); touch(t.path / "root" / "usr" / "include" / "features.h"); touch(t.path / "root" / "lib64" / "libc.so.6"); - EXPECT_FALSE(tb::is_payload_layout(t.path / "root")); -} + expect(!tb::is_payload_layout(t.path / "root")); +}}; // The identity moves when a file the measurement rested on is rewritten in // place, and only then. -TEST(TargetBaseIdentity, FollowsTheEvidence) { +UTest follows_the_evidence{"TargetBaseIdentity/FollowsTheEvidence", [] { Tmp t; const auto lib = t.path / "lib64" / "libc.so.6"; touch(lib, "one"); const auto a = tb::identity_of("path:/x", t.path, {lib}, "glibc 2.31"); - EXPECT_EQ(a, tb::identity_of("path:/x", t.path, {lib}, "glibc 2.31")); + expect(eq(a, tb::identity_of("path:/x", t.path, {lib}, "glibc 2.31"))); touch(lib, "a longer file"); - EXPECT_NE(a, tb::identity_of("path:/x", t.path, {lib}, "glibc 2.31")); - EXPECT_NE(a, tb::identity_of("path:/y", t.path, {lib}, "glibc 2.31")); -} + expect(ne(a, tb::identity_of("path:/x", t.path, {lib}, "glibc 2.31"))); + expect(ne(a, tb::identity_of("path:/y", t.path, {lib}, "glibc 2.31"))); +}}; // Apple's own convention, on every host: `SDKROOT` names the SDK, its // `SDKSettings.json` its version, its `libSystem.tbd` its slices. -TEST(TargetBaseApple, SdkrootOnEveryHost) { +UTest sdkroot_on_every_host{"TargetBaseApple/SdkrootOnEveryHost", [] { Tmp t; const auto sdk = t.path / "iPhoneSimulator18.5.sdk"; touch(sdk / "SDKSettings.json", R"({"CanonicalName": "iphonesimulator18.5", "Version": "18.5"})"); @@ -199,30 +200,30 @@ TEST(TargetBaseApple, SdkrootOnEveryHost) { "--- !tapi-tbd\ntbd-version: 4\ntargets: [ x86_64-ios-simulator, arm64-ios-simulator ]\n"); mcpp::invocation::ScopedEnv env("SDKROOT", sdk.string()); auto found = tb::locate_apple(*tb::parse_text("iphonesimulator@system"), "iphonesimulator"); - ASSERT_TRUE(found) << found.error(); - EXPECT_EQ(found->root, sdk); - EXPECT_EQ(found->version, "18.5"); - EXPECT_FALSE(tb::locate_apple(*tb::parse_text("iphonesimulator@17.0"), "iphonesimulator")); + if (auto&& failed = expect(fatal(bool(found))); !failed) failed << found.error(); + expect(eq(found->root, sdk)); + expect(eq(found->version, "18.5")); + expect(!(tb::locate_apple(*tb::parse_text("iphonesimulator@17.0"), "iphonesimulator"))); // SDKROOT names one SDK and does not answer for another. (On an Apple // host `xcrun` answers next, and Xcode's iPhoneOS SDK is found there.) if (!mcpplibs::os::is_macos) - EXPECT_FALSE(tb::locate_apple(*tb::parse_text("iphoneos@system"), "iphoneos")); + expect(!(tb::locate_apple(*tb::parse_text("iphoneos@system"), "iphoneos"))); using mcpp::toolchain::apple::Slice; - EXPECT_EQ(mcpp::toolchain::apple::sdk_has_target(sdk, "x86_64-ios-simulator"), Slice::carried); - EXPECT_EQ(mcpp::toolchain::apple::sdk_has_target(sdk, "x86_64-ios"), Slice::absent); -} + expect(eq(mcpp::toolchain::apple::sdk_has_target(sdk, "x86_64-ios-simulator"), Slice::carried)); + expect(eq(mcpp::toolchain::apple::sdk_has_target(sdk, "x86_64-ios"), Slice::absent)); +}}; -TEST(TargetBaseApple, PathMustBeAnSdk) { +UTest path_must_be_an_sdk{"TargetBaseApple/PathMustBeAnSdk", [] { Tmp t; auto none = tb::locate_apple(*tb::parse_text("path:" + (t.path / "nothing").string()), "macosx"); - EXPECT_FALSE(none); + expect(!none); touch(t.path / "empty" / "readme"); - EXPECT_FALSE(tb::locate_apple(*tb::parse_text("path:" + (t.path / "empty").string()), "macosx")); -} + expect(!(tb::locate_apple(*tb::parse_text("path:" + (t.path / "empty").string()), "macosx"))); +}}; // THE LINKER IS GIVEN THE SYSROOT, AND A BINDING ARTIFACT LOADS THE BASE: the // two defects #755's sysroot had (scenarios C and D). -TEST(TargetBaseLinkModel, StatedBindingBaseNamesItsLoaderAndDirs) { +UTest stated_binding_base_names_its_loader_and_dirs{"TargetBaseLinkModel/StatedBindingBaseNamesItsLoaderAndDirs", [] { tc::Toolchain t; t.compiler = tc::CompilerId::GCC; t.targetTriple = "x86_64-linux-gnu"; @@ -234,22 +235,22 @@ TEST(TargetBaseLinkModel, StatedBindingBaseNamesItsLoaderAndDirs) { t.base.loader = "/sr/lib64/ld-linux-x86-64.so.2"; t.base.runtimeDirs = {"/sr/lib64"}; auto lm = tc::resolve_link_model(t); - EXPECT_EQ(lm.mode, tc::CLibMode::Sysroot); + expect(eq(lm.mode, tc::CLibMode::Sysroot)); auto link = lm.link_tokens(ident); - EXPECT_TRUE(has(link, "--sysroot=/sr")); - EXPECT_TRUE(has(link, "-Wl,--sysroot=/sr")); - EXPECT_TRUE(has(link, "-Wl,--dynamic-linker=/sr/lib64/ld-linux-x86-64.so.2")); - EXPECT_TRUE(has(link, "-Wl,-rpath,/sr/lib64")); + expect(bool(has(link, "--sysroot=/sr"))); + expect(bool(has(link, "-Wl,--sysroot=/sr"))); + expect(bool(has(link, "-Wl,--dynamic-linker=/sr/lib64/ld-linux-x86-64.so.2"))); + expect(bool(has(link, "-Wl,-rpath,/sr/lib64"))); // A floor (a cross target) names no loader: the pristine spec's standard // path is the one the target machine has. t.base.runtime = tb::Runtime::Floor; auto floor = tc::resolve_link_model(t).link_tokens(ident); - EXPECT_FALSE(std::ranges::any_of(floor, [](auto const& s) { - return s.starts_with("-Wl,--dynamic-linker"); })); -} + expect(!(std::ranges::any_of(floor, [](auto const& s) { + return s.starts_with("-Wl,--dynamic-linker"); }))); +}}; -TEST(TargetBaseLinkModel, MultiarchForADriverThatDoesNotKnowIt) { +UTest multiarch_for_a_driver_that_does_not_know_it{"TargetBaseLinkModel/MultiarchForADriverThatDoesNotKnowIt", [] { tc::Toolchain t; t.compiler = tc::CompilerId::GCC; t.targetTriple = "x86_64-linux-gnu"; @@ -265,15 +266,15 @@ TEST(TargetBaseLinkModel, MultiarchForADriverThatDoesNotKnowIt) { // operators, so a Windows host writes its own separator. namespace fs = std::filesystem; const auto root = fs::path("/"); - EXPECT_TRUE(has(lm.compile_tokens(ident), - "-idirafter" + (root / "usr" / "include" / "x86_64-linux-gnu").string())); + expect(bool(has(lm.compile_tokens(ident), + "-idirafter" + (root / "usr" / "include" / "x86_64-linux-gnu").string()))); auto link = lm.link_tokens(ident); - EXPECT_TRUE(has(link, "-B" + (root / "usr" / "lib" / "x86_64-linux-gnu").string())); - EXPECT_TRUE(has(link, "-Wl,-rpath-link," + (root / "lib" / "x86_64-linux-gnu").string())); -} - -TEST(PasteArg, QuotesOnlyWhatTheShellWouldSplit) { - EXPECT_EQ(mcpp::build::command_line::paste_arg("/tmp/home"), "/tmp/home"); - EXPECT_NE(mcpp::build::command_line::paste_arg("/tmp/mc probe/home"), "/tmp/mc probe/home"); - EXPECT_NE(mcpp::build::command_line::paste_arg("/tmp/a'b"), "/tmp/a'b"); -} + expect(bool(has(link, "-B" + (root / "usr" / "lib" / "x86_64-linux-gnu").string()))); + expect(bool(has(link, "-Wl,-rpath-link," + (root / "lib" / "x86_64-linux-gnu").string()))); +}}; + +UTest quotes_only_what_the_shell_would_split{"PasteArg/QuotesOnlyWhatTheShellWouldSplit", [] { + expect(eq(mcpp::build::command_line::paste_arg("/tmp/home"), "/tmp/home")); + expect(ne(mcpp::build::command_line::paste_arg("/tmp/mc probe/home"), "/tmp/mc probe/home")); + expect(ne(mcpp::build::command_line::paste_arg("/tmp/a'b"), "/tmp/a'b")); +}}; diff --git a/tests/unit/test_target_row_spelling.cpp b/tests/unit/test_target_row_spelling.cpp index 1df8135a3..ec7b6f49c 100644 --- a/tests/unit/test_target_row_spelling.cpp +++ b/tests/unit/test_target_row_spelling.cpp @@ -1,10 +1,12 @@ -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.toolchain.triple; import mcpp.build.prepare_inputs; +using namespace mcpplibs::mtest; + // A `[target.]` row is one row whichever spelling names it. Every // reader of a row asks `find_target_entry`; before 2026.10.5.3 the readers of // `runner` and `min_api_level` compared the key exactly, so a section written @@ -13,7 +15,7 @@ import mcpp.build.prepare_inputs; namespace tr = mcpp::toolchain::triple; -TEST(TargetRowSpelling, EverySpellingOfATripleFindsItsRow) { +UTest every_spelling_of_a_triple_finds_its_row{"TargetRowSpelling/EverySpellingOfATripleFindsItsRow", [] { auto m = mcpp::manifest::parse_string(R"( [package] name = "p" @@ -28,18 +30,18 @@ runner = ["wine64"] [target.i386-windows-msvc] runner = ["i386"] )"); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto runner_of = [&](std::string_view triple) -> std::string { auto t = tr::parse(triple); if (!t) return ""; auto* row = mcpp::build::find_target_entry(*m, *t); return row && !row->runner.empty() ? row->runner.front() : ""; }; - EXPECT_EQ(runner_of("i686-windows-msvc"), "wine"); - EXPECT_EQ(runner_of("i686-pc-windows-msvc"), "wine"); - EXPECT_EQ(runner_of("x86-windows-msvc"), "wine"); - EXPECT_EQ(runner_of("x86_64-windows-gnu"), "wine64"); + expect(eq(runner_of("i686-windows-msvc"), "wine")); + expect(eq(runner_of("i686-pc-windows-msvc"), "wine")); + expect(eq(runner_of("x86-windows-msvc"), "wine")); + expect(eq(runner_of("x86_64-windows-gnu"), "wine64")); // A different baseline CPU is a different row. - EXPECT_EQ(runner_of("i386-windows-msvc"), "i386"); - EXPECT_EQ(runner_of("i586-windows-msvc"), ""); -} + expect(eq(runner_of("i386-windows-msvc"), "i386")); + expect(eq(runner_of("i586-windows-msvc"), "")); +}}; diff --git a/tests/unit/test_target_rows_snapshot.cpp b/tests/unit/test_target_rows_snapshot.cpp index 573313b5c..c72366289 100644 --- a/tests/unit/test_target_rows_snapshot.cpp +++ b/tests/unit/test_target_rows_snapshot.cpp @@ -1,11 +1,13 @@ -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.triple; import mcpp.toolchain.platform; import mcpp.freestanding.target; import mcpp.freestanding.linkline; +using namespace mcpplibs::mtest; + // THE EQUIVALENCE GATE OF THE TARGET MODEL (design 2026-11-09 §9, §14). // // The target rows moved from code to data. Every fact a row used to answer in @@ -135,16 +137,16 @@ std::optional snapshot_path() { } // namespace -TEST(TargetRows, EveryBuiltInRowAnswersAsTheSnapshotRecords) { +UTest every_built_in_row_answers_as_the_snapshot_records{"TargetRows/EveryBuiltInRowAnswersAsTheSnapshotRecords", [] { auto path = snapshot_path(); - ASSERT_TRUE(path) << "tests/fixtures not found from " << std::filesystem::current_path(); + expect(fatal(bool(path))) << "tests/fixtures not found from " << std::filesystem::current_path(); const auto now = render(); if (const char* u = std::getenv("MCPP_SNAPSHOT_UPDATE"); u && std::string_view(u) == "1") { std::ofstream(*path, std::ios::binary) << now; - GTEST_SKIP() << "snapshot written to " << path->generic_string(); + skip([&] { std::ostringstream text; text << "snapshot written to " << path->generic_string(); return text.str(); }()); } std::ifstream in(*path, std::ios::binary); - ASSERT_TRUE(in) << "no snapshot at " << path->generic_string(); + if (auto&& failed = expect(fatal(bool(in))); !failed) failed << "no snapshot at " << path->generic_string(); const std::string was{std::istreambuf_iterator(in), {}}; if (now == was) return; // The first differing line, with its row, so the failure names the fact. @@ -155,7 +157,6 @@ TEST(TargetRows, EveryBuiltInRowAnswersAsTheSnapshotRecords) { if (!ga && !gb) break; if (gb && (lb.starts_with("row ") || lb.starts_with("request "))) row = lb; if (la != lb || ga != gb) - FAIL() << "line " << n << " (" << row << ")\n snapshot: " << (ga ? la : "") - << "\n now: " << (gb ? lb : ""); + fail([&] { std::ostringstream text; text << "line " << n << " (" << row << ")\n snapshot: " << (ga ? la : "") << "\n now: " << (gb ? lb : ""); return text.str(); }()); } -} +}}; diff --git a/tests/unit/test_target_runtime_frameworks.cpp b/tests/unit/test_target_runtime_frameworks.cpp index 09da61d93..e076e50ee 100644 --- a/tests/unit/test_target_runtime_frameworks.cpp +++ b/tests/unit/test_target_runtime_frameworks.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.build.prepare; +using namespace mcpplibs::mtest; + namespace cfgpred = mcpp::build::cfgpred; // `[target..runtime] frameworks` (#622 A2). The one Mach-O key that @@ -31,7 +33,7 @@ frameworks = ["UIKit"] std::vector frameworks_for(std::string_view triple) { auto m = mcpp::manifest::parse_string(kSrc); - EXPECT_TRUE(m.has_value()); + expect(m.has_value()); if (!m) return {}; mcpp::build::merge_conditional_config(*m, cfgpred::context_for(triple)); return m->runtimeConfig.linkIntent.frameworks; @@ -39,11 +41,11 @@ std::vector frameworks_for(std::string_view triple) { } // namespace -TEST(TargetRuntimeFrameworks, TheSelectorDecidesAndTheTopLevelStays) { +UTest the_selector_decides_and_the_top_level_stays{"TargetRuntimeFrameworks/TheSelectorDecidesAndTheTopLevelStays", [] { const std::vector mac{"Foundation", "AppKit"}; const std::vector ios{"Foundation", "UIKit"}; const std::vector lin{"Foundation"}; - EXPECT_EQ(frameworks_for("aarch64-macos"), mac); - EXPECT_EQ(frameworks_for("aarch64-ios-sim"), ios); - EXPECT_EQ(frameworks_for("x86_64-linux-gnu"), lin); -} + expect(eq(frameworks_for("aarch64-macos"), mac)); + expect(eq(frameworks_for("aarch64-ios-sim"), ios)); + expect(eq(frameworks_for("x86_64-linux-gnu"), lin)); +}}; diff --git a/tests/unit/test_target_scalar_keys.cpp b/tests/unit/test_target_scalar_keys.cpp index 3f97f35f5..197aefd6a 100644 --- a/tests/unit/test_target_scalar_keys.cpp +++ b/tests/unit/test_target_scalar_keys.cpp @@ -1,6 +1,8 @@ -#include import std; +import mcpplibs.mtest; + +using namespace mcpplibs::mtest; // #610 / #630 item 5 — `[target.]` has a scalar/array parser (a // sequence of `body.find("")` blocks) and, further down in the same @@ -127,28 +129,28 @@ std::vector known_list(const std::string& region, std::string_view } // namespace -TEST(TargetScalarKeys, EveryParsedNonTableKeyIsKnownToTheSweep) { +UTest every_parsed_non_table_key_is_known_to_the_sweep{"TargetScalarKeys/EveryParsedNonTableKeyIsKnownToTheSweep", [] { auto source = read_file(repo_root() / "modules" / "manifest" / "src" / "toml.cppm"); - ASSERT_FALSE(source.empty()) << "could not read toml.cppm"; + expect(fatal(!source.empty())) << "could not read toml.cppm"; auto region = target_entry_region(source); - ASSERT_FALSE(region.empty()) << "could not locate the [target.] entry parser"; + if (auto&& failed = expect(fatal(!region.empty())); !failed) failed << "could not locate the [target.] entry parser"; auto parsed = parsed_target_keys(region); auto knownScalars = known_list(region, "kKnownTargetScalars"); auto knownArrays = known_list(region, "kKnownTargetArrays"); - ASSERT_FALSE(knownScalars.empty()); - ASSERT_FALSE(knownArrays.empty()); + expect(fatal(!knownScalars.empty())); + expect(fatal(!knownArrays.empty())); // Direction 1 (the positive claim, and the failure mode #610 produced): // every key the parser reads as a scalar is in the sweep's scalar list, // and likewise for arrays. `min_api_level` is the case that used to fail // this. for (auto const& key : parsed.scalars) - EXPECT_NE(std::ranges::find(knownScalars, key), knownScalars.end()) + expect(ne(std::ranges::find(knownScalars, key), knownScalars.end())) << "'" << key << "' is parsed as a scalar but missing from " << "kKnownTargetScalars, so the sweep would report it as unsupported"; for (auto const& key : parsed.arrays) - EXPECT_NE(std::ranges::find(knownArrays, key), knownArrays.end()) + expect(ne(std::ranges::find(knownArrays, key), knownArrays.end())) << "'" << key << "' is parsed as an array but missing from " << "kKnownTargetArrays"; @@ -156,14 +158,14 @@ TEST(TargetScalarKeys, EveryParsedNonTableKeyIsKnownToTheSweep) { // names something the parser reads. A stale list entry would pass every // manifest silently and give no test failure otherwise. for (auto const& key : knownScalars) - EXPECT_NE(std::ranges::find(parsed.scalars, key), parsed.scalars.end()) + expect(ne(std::ranges::find(parsed.scalars, key), parsed.scalars.end())) << "kKnownTargetScalars names '" << key << "', which has no " << "body.find(...) parse site in the [target.] entry parser"; for (auto const& key : knownArrays) - EXPECT_NE(std::ranges::find(parsed.arrays, key), parsed.arrays.end()) + expect(ne(std::ranges::find(parsed.arrays, key), parsed.arrays.end())) << "kKnownTargetArrays names '" << key << "', which has no " << "body.find(...) parse site"; -} +}}; // The whole `[target.]` loop body: the scalar parser, the sweep, and // the conditional channel that follows it, up to the point the block is @@ -204,43 +206,43 @@ std::vector parsed_target_tables(const std::string& region) { return out; } -TEST(TargetScalarKeys, EveryParsedSubTableIsKnownToTheSweep) { +UTest every_parsed_sub_table_is_known_to_the_sweep{"TargetScalarKeys/EveryParsedSubTableIsKnownToTheSweep", [] { // #634: the sweep used to skip every table-valued key, so a misspelled // section, and a section an older engine does not read, did nothing // without a word. It now checks tables against `kKnownTargetTables`, and // this test holds that list to the parse sites in both directions, which // is the drift the skip was introduced to avoid. auto source = read_file(repo_root() / "modules" / "manifest" / "src" / "toml.cppm"); - ASSERT_FALSE(source.empty()) << "could not read toml.cppm"; + expect(fatal(!source.empty())) << "could not read toml.cppm"; auto region = target_loop_region(source); - ASSERT_FALSE(region.empty()) << "could not locate the [target.] loop body"; + if (auto&& failed = expect(fatal(!region.empty())); !failed) failed << "could not locate the [target.] loop body"; auto parsed = parsed_target_tables(region); auto known = known_list(region, "kKnownTargetTables[]"); - ASSERT_FALSE(parsed.empty()); - ASSERT_FALSE(known.empty()); + expect(fatal(!parsed.empty())); + expect(fatal(!known.empty())); for (auto const& key : parsed) - EXPECT_NE(std::ranges::find(known, key), known.end()) + expect(ne(std::ranges::find(known, key), known.end())) << "'" << key << "' is parsed as a sub-table of [target.] " << "but missing from kKnownTargetTables, so the sweep would report it"; for (auto const& key : known) - EXPECT_NE(std::ranges::find(parsed, key), parsed.end()) + expect(ne(std::ranges::find(parsed, key), parsed.end())) << "kKnownTargetTables names '" << key << "', which has no parse site " << "in the [target.] loop body"; // The table this list was introduced beside. - EXPECT_NE(std::ranges::find(parsed, std::string("targets")), parsed.end()); -} + expect(ne(std::ranges::find(parsed, std::string("targets")), parsed.end())); +}}; -TEST(TargetScalarKeys, MinApiLevelIsParsedAndKnown) { +UTest min_api_level_is_parsed_and_known{"TargetScalarKeys/MinApiLevelIsParsedAndKnown", [] { // A direct, non-derived check on the specific regression: `min_api_level` // must be both a parse site and a known scalar key. If this test passes // while the source-scan test above also passes, the fix is coherent; if // this one passes and the scan test fails, a *different* key drifted. auto source = read_file(repo_root() / "modules" / "manifest" / "src" / "toml.cppm"); auto region = target_entry_region(source); - ASSERT_FALSE(region.empty()); - EXPECT_NE(region.find("body.find(\"min_api_level\")"), std::string::npos); + expect(fatal(!region.empty())); + expect(ne(region.find("body.find(\"min_api_level\")"), std::string::npos)); auto knownScalars = known_list(region, "kKnownTargetScalars"); - EXPECT_NE(std::ranges::find(knownScalars, std::string("min_api_level")), knownScalars.end()); -} + expect(ne(std::ranges::find(knownScalars, std::string("min_api_level")), knownScalars.end())); +}}; diff --git a/tests/unit/test_target_set.cpp b/tests/unit/test_target_set.cpp index dfe543eda..516aa1b53 100644 --- a/tests/unit/test_target_set.cpp +++ b/tests/unit/test_target_set.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.triple; +using namespace mcpplibs::mtest; + namespace ts = mcpp::toolchain::target_set; namespace tt = mcpp::toolchain::triple; @@ -18,7 +20,7 @@ std::expected column_platform(const tt::Triple& t) { std::vector legs_of(std::initializer_list requests) { std::vector req(requests); auto r = ts::resolve(req, column_platform); - EXPECT_TRUE(r.has_value()) << (r ? "" : r.error()); + if (auto&& failed = expect(r.has_value()); !failed) failed << (r ? "" : r.error()); std::vector out; if (r) for (auto& l : *r) out.push_back(l.triple); return out; @@ -28,143 +30,143 @@ std::vector legs_of(std::initializer_list requests) { // ── recognition: the first segment, not a failed parse ────────────────────── -TEST(TargetSet, RecognisesSystemWords) { - EXPECT_TRUE(ts::is_set_spelling("openkal")); - EXPECT_TRUE(ts::is_set_spelling("linux-musl")); - EXPECT_TRUE(ts::is_set_spelling("none-eabihf")); - EXPECT_TRUE(ts::is_set_spelling("android")); - EXPECT_FALSE(ts::is_set_spelling("x86_64-openkal")); - EXPECT_FALSE(ts::is_set_spelling("aarch64-linux-musl")); -} +UTest recognises_system_words{"TargetSet/RecognisesSystemWords", [] { + expect(ts::is_set_spelling("openkal")); + expect(ts::is_set_spelling("linux-musl")); + expect(ts::is_set_spelling("none-eabihf")); + expect(ts::is_set_spelling("android")); + expect(!ts::is_set_spelling("x86_64-openkal")); + expect(!ts::is_set_spelling("aarch64-linux-musl")); +}}; -TEST(TargetSet, VendorFirstSpellingIsATriple) { +UTest vendor_first_spelling_is_a_triple{"TargetSet/VendorFirstSpellingIsATriple", [] { // `unknown-linux-gnu` parses as a triple (architecture `unknown`); it is // not a set, because `unknown` is not a system word. - EXPECT_FALSE(ts::is_set_spelling("unknown-linux-gnu")); - EXPECT_FALSE(ts::is_set_spelling("apple-macos")); + expect(!ts::is_set_spelling("unknown-linux-gnu")); + expect(!ts::is_set_spelling("apple-macos")); auto legs = legs_of({"unknown-linux-gnu"}); - ASSERT_EQ(legs.size(), 1u); - EXPECT_EQ(legs.front(), "unknown-linux-gnu"); -} + expect(fatal(eq(legs.size(), 1u))); + expect(eq(legs.front(), "unknown-linux-gnu")); +}}; // ── expansion ──────────────────────────────────────────────────────────────── -TEST(TargetSet, OpenkalNamesBothArchitectures) { +UTest openkal_names_both_architectures{"TargetSet/OpenkalNamesBothArchitectures", [] { auto e = ts::expand_set("openkal", column_platform); - ASSERT_TRUE(e.has_value()) << e.error(); - EXPECT_EQ(e->members, (std::vector{"x86_64-openkal", "aarch64-openkal"})); - EXPECT_TRUE(e->platform.starts_with("mcpplibs.openkal-platform@")); -} + if (auto&& failed = expect(fatal(e.has_value())); !failed) failed << e.error(); + expect(eq(e->members, (std::vector{"x86_64-openkal", "aarch64-openkal"}))); + expect(e->platform.starts_with("mcpplibs.openkal-platform@")); +}}; -TEST(TargetSet, LinuxMuslLeavesPlannedRowsOut) { +UTest linux_musl_leaves_planned_rows_out{"TargetSet/LinuxMuslLeavesPlannedRowsOut", [] { auto e = ts::expand_set("linux-musl", column_platform); - ASSERT_TRUE(e.has_value()) << e.error(); - EXPECT_EQ(e->members, (std::vector{"x86_64-linux-musl", "aarch64-linux-musl"})); - for (auto& m : e->members) EXPECT_NE(m, "riscv64-linux-musl"); -} + if (auto&& failed = expect(fatal(e.has_value())); !failed) failed << e.error(); + expect(eq(e->members, (std::vector{"x86_64-linux-musl", "aarch64-linux-musl"}))); + for (auto& m : e->members) expect(ne(m, "riscv64-linux-musl")); +}}; -TEST(TargetSet, LinuxFillsGnuAndLeavesAndroidOut) { +UTest linux_fills_gnu_and_leaves_android_out{"TargetSet/LinuxFillsGnuAndLeavesAndroidOut", [] { auto e = ts::expand_set("linux", column_platform); - ASSERT_TRUE(e.has_value()) << e.error(); - EXPECT_EQ(e->members, (std::vector{"x86_64-linux-gnu", "aarch64-linux-gnu"})); -} + if (auto&& failed = expect(fatal(e.has_value())); !failed) failed << e.error(); + expect(eq(e->members, (std::vector{"x86_64-linux-gnu", "aarch64-linux-gnu"}))); +}}; -TEST(TargetSet, WindowsFillsGnu) { +UTest windows_fills_gnu{"TargetSet/WindowsFillsGnu", [] { auto e = ts::expand_set("windows", column_platform); - ASSERT_TRUE(e.has_value()) << e.error(); - EXPECT_EQ(e->members, (std::vector{"x86_64-windows-gnu"})); -} + if (auto&& failed = expect(fatal(e.has_value())); !failed) failed << e.error(); + expect(eq(e->members, (std::vector{"x86_64-windows-gnu"}))); +}}; -TEST(TargetSet, AndroidIsTheLinuxAndroidRows) { +UTest android_is_the_linux_android_rows{"TargetSet/AndroidIsTheLinuxAndroidRows", [] { auto e = ts::expand_set("android", column_platform); - ASSERT_TRUE(e.has_value()) << e.error(); - EXPECT_EQ(e->members, - (std::vector{"aarch64-linux-android", "x86_64-linux-android"})); -} + if (auto&& failed = expect(fatal(e.has_value())); !failed) failed << e.error(); + expect(eq(e->members, (std::vector{"aarch64-linux-android", "x86_64-linux-android"}))); +}}; -TEST(TargetSet, IosAndItsSimulator) { +UTest ios_and_its_simulator{"TargetSet/IosAndItsSimulator", [] { auto device = ts::expand_set("ios", column_platform); - ASSERT_TRUE(device.has_value()) << device.error(); - EXPECT_EQ(device->members, (std::vector{"aarch64-ios"})); + if (auto&& failed = expect(fatal(device.has_value())); !failed) failed << device.error(); + expect(eq(device->members, (std::vector{"aarch64-ios"}))); auto sim = ts::expand_set("ios-sim", column_platform); - ASSERT_TRUE(sim.has_value()) << sim.error(); - EXPECT_EQ(sim->members, (std::vector{"aarch64-ios-sim", "x86_64-ios-sim"})); -} + if (auto&& failed = expect(fatal(sim.has_value())); !failed) failed << sim.error(); + expect(eq(sim->members, (std::vector{"aarch64-ios-sim", "x86_64-ios-sim"}))); +}}; -TEST(TargetSet, BareMetalNeedsItsEnvironment) { +UTest bare_metal_needs_its_environment{"TargetSet/BareMetalNeedsItsEnvironment", [] { auto bare = ts::expand_set("none", column_platform); - ASSERT_FALSE(bare.has_value()); - EXPECT_NE(bare.error().find("none-elf"), std::string::npos); + expect(fatal(!bare.has_value())); + expect(ne(bare.error().find("none-elf"), std::string::npos)); auto elf = ts::expand_set("none-elf", column_platform); - ASSERT_TRUE(elf.has_value()) << elf.error(); - EXPECT_EQ(elf->members, (std::vector{ - "riscv64-none-elf", "riscv32-none-elf", "aarch64-none-elf", "x86_64-none-elf"})); -} + if (auto&& failed = expect(fatal(elf.has_value())); !failed) failed << elf.error(); + expect(eq(elf->members, (std::vector{ + "riscv64-none-elf", "riscv32-none-elf", "aarch64-none-elf", "x86_64-none-elf", + "i686-none-elf"}))); +}}; -TEST(TargetSet, EmptySetIsRefusedNamingThePlannedRows) { +UTest empty_set_is_refused_naming_the_planned_rows{"TargetSet/EmptySetIsRefusedNamingThePlannedRows", [] { auto e = ts::expand_set("macos-gnu", column_platform); - ASSERT_FALSE(e.has_value()); + expect(fatal(!e.has_value())); auto none = ts::expand_set("linux-msvc", column_platform); - ASSERT_FALSE(none.has_value()); - EXPECT_NE(none.error().find("names no target"), std::string::npos); -} - -TEST(TargetSet, MalformedSetsAreRefused) { - EXPECT_FALSE(ts::expand_set("linux-musl-extra", column_platform).has_value()); - EXPECT_FALSE(ts::expand_set("linux-", column_platform).has_value()); - EXPECT_FALSE(ts::expand_set("openkal-gnu", column_platform).has_value()); - EXPECT_FALSE(ts::expand_set("android-x", column_platform).has_value()); -} - -TEST(TargetSet, MembersSelectingDifferentPlatformsAreRefused) { + expect(fatal(!none.has_value())); + expect(ne(none.error().find("names no target"), std::string::npos)); +}}; + +UTest malformed_sets_are_refused{"TargetSet/MalformedSetsAreRefused", [] { + expect(!(ts::expand_set("linux-musl-extra", column_platform).has_value())); + expect(!(ts::expand_set("linux-", column_platform).has_value())); + expect(!(ts::expand_set("openkal-gnu", column_platform).has_value())); + expect(!(ts::expand_set("android-x", column_platform).has_value())); +}}; + +UTest members_selecting_different_platforms_are_refused{"TargetSet/MembersSelectingDifferentPlatformsAreRefused", [] { auto split = [](const tt::Triple& t) -> std::expected { return t.arch == "x86_64" ? std::string("a@1") : std::string("b@1"); }; auto e = ts::expand_set("openkal", split); - ASSERT_FALSE(e.has_value()); - EXPECT_NE(e.error().find("select different platforms"), std::string::npos); - EXPECT_NE(e.error().find("x86_64-openkal"), std::string::npos); - EXPECT_NE(e.error().find("aarch64-openkal"), std::string::npos); -} + expect(fatal(!e.has_value())); + expect(ne(e.error().find("select different platforms"), std::string::npos)); + expect(ne(e.error().find("x86_64-openkal"), std::string::npos)); + expect(ne(e.error().find("aarch64-openkal"), std::string::npos)); +}}; -TEST(TargetSet, ASelectionErrorNamesTheMember) { +UTest a_selection_error_names_the_member{"TargetSet/ASelectionErrorNamesTheMember", [] { auto broken = [](const tt::Triple&) -> std::expected { return std::unexpected(std::string("no such platform")); }; auto e = ts::expand_set("openkal", broken); - ASSERT_FALSE(e.has_value()); - EXPECT_NE(e.error().find("x86_64-openkal: no such platform"), std::string::npos); -} + expect(fatal(!e.has_value())); + expect(ne(e.error().find("x86_64-openkal: no such platform"), std::string::npos)); +}}; // ── several --target ───────────────────────────────────────────────────────── -TEST(TargetSet, UnionInTableOrderWithoutDuplicates) { +UTest union_in_table_order_without_duplicates{"TargetSet/UnionInTableOrderWithoutDuplicates", [] { auto legs = legs_of({"aarch64-openkal", "openkal", "x86_64-linux-musl"}); - EXPECT_EQ(legs, (std::vector{ - "x86_64-linux-musl", "x86_64-openkal", "aarch64-openkal"})); -} + expect(eq(legs, (std::vector{ + "x86_64-linux-musl", "x86_64-openkal", "aarch64-openkal"}))); +}}; -TEST(TargetSet, ATargetKeepsItsSpelling) { +UTest a_target_keeps_its_spelling{"TargetSet/ATargetKeepsItsSpelling", [] { // Planned as the command would plan it alone: `x86_64-linux` is completed // and reported by the build, not here. auto legs = legs_of({"x86_64-linux"}); - ASSERT_EQ(legs.size(), 1u); - EXPECT_EQ(legs.front(), "x86_64-linux"); + expect(fatal(eq(legs.size(), 1u))); + expect(eq(legs.front(), "x86_64-linux")); auto twice = legs_of({"x86_64-linux", "x86_64-linux-gnu"}); - EXPECT_EQ(twice.size(), 1u); -} + expect(eq(twice.size(), 1u)); +}}; -TEST(TargetSet, UnknownTargetsFollowTheTableInWrittenOrder) { +UTest unknown_targets_follow_the_table_in_written_order{"TargetSet/UnknownTargetsFollowTheTableInWrittenOrder", [] { auto legs = legs_of({"zz-custom-target", "x86_64-openkal", "yy-other"}); - EXPECT_EQ(legs, (std::vector{"x86_64-openkal", "zz-custom-target", "yy-other"})); -} + expect(eq(legs, (std::vector{"x86_64-openkal", "zz-custom-target", "yy-other"}))); +}}; -TEST(TargetSet, LegsRecordTheirSet) { +UTest legs_record_their_set{"TargetSet/LegsRecordTheirSet", [] { std::vector req{"openkal", "x86_64-linux-musl"}; auto r = ts::resolve(req, column_platform); - ASSERT_TRUE(r.has_value()); + expect(fatal(r.has_value())); for (auto& l : *r) { - if (l.triple.ends_with("openkal")) EXPECT_EQ(l.fromSet, "openkal"); - else EXPECT_TRUE(l.fromSet.empty()); + if (l.triple.ends_with("openkal")) expect(eq(l.fromSet, "openkal")); + else expect(l.fromSet.empty()); } -} +}}; diff --git a/tests/unit/test_target_static.cpp b/tests/unit/test_target_static.cpp index 921c710ce..01643d54d 100644 --- a/tests/unit/test_target_static.cpp +++ b/tests/unit/test_target_static.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.model; import mcpp.toolchain.platform; +using namespace mcpplibs::mtest; + // Whether the produced artifact can be fully statically linked (`-static`) is // a property of the TARGET, never of the machine doing the build. // @@ -24,54 +26,54 @@ namespace tc = mcpp::toolchain; // ── The regression itself ─────────────────────────────────────────────────── // Every one of these runs a NON-Linux host (hostCapability=false) against a // Linux target. Before the fix all four returned false. -TEST(TargetSupportsFullStatic, LinuxTargetFromNonLinuxHost) { - EXPECT_TRUE(tc::target_supports_full_static("x86_64-linux-musl", false)); - EXPECT_TRUE(tc::target_supports_full_static("aarch64-linux-musl", false)); - EXPECT_TRUE(tc::target_supports_full_static("x86_64-linux-gnu", false)); +UTest linux_target_from_non_linux_host{"TargetSupportsFullStatic/LinuxTargetFromNonLinuxHost", [] { + expect(bool(tc::target_supports_full_static("x86_64-linux-musl", false))); + expect(bool(tc::target_supports_full_static("aarch64-linux-musl", false))); + expect(bool(tc::target_supports_full_static("x86_64-linux-gnu", false))); // Legacy/alternate spellings must resolve identically — the triple parser // is the single source of truth, not a substring match. - EXPECT_TRUE(tc::target_supports_full_static("x86_64-unknown-linux-musl", false)); -} + expect(bool(tc::target_supports_full_static("x86_64-unknown-linux-musl", false))); +}}; // A Linux host keeps answering exactly as before: this fix must be a no-op // on every path that works today. -TEST(TargetSupportsFullStatic, LinuxTargetFromLinuxHostUnchanged) { - EXPECT_TRUE(tc::target_supports_full_static("x86_64-linux-musl", true)); - EXPECT_TRUE(tc::target_supports_full_static("aarch64-linux-musl", true)); - EXPECT_TRUE(tc::target_supports_full_static("x86_64-linux-gnu", true)); -} +UTest linux_target_from_linux_host_unchanged{"TargetSupportsFullStatic/LinuxTargetFromLinuxHostUnchanged", [] { + expect(bool(tc::target_supports_full_static("x86_64-linux-musl", true))); + expect(bool(tc::target_supports_full_static("aarch64-linux-musl", true))); + expect(bool(tc::target_supports_full_static("x86_64-linux-gnu", true))); +}}; // ── Targets that genuinely cannot be fully static ─────────────────────────── // macOS: libSystem must stay dynamic, no matter who builds it. -TEST(TargetSupportsFullStatic, MacosTargetNeverStatic) { - EXPECT_FALSE(tc::target_supports_full_static("aarch64-macos", true)); - EXPECT_FALSE(tc::target_supports_full_static("aarch64-macos", false)); - EXPECT_FALSE(tc::target_supports_full_static("x86_64-macos", true)); -} +UTest macos_target_never_static{"TargetSupportsFullStatic/MacosTargetNeverStatic", [] { + expect(!tc::target_supports_full_static("aarch64-macos", true)); + expect(!tc::target_supports_full_static("aarch64-macos", false)); + expect(!tc::target_supports_full_static("x86_64-macos", true)); +}}; // PE targets take their `-static` from the C++ runtime distribution contract // (dist::Format::Pe in flags.cppm), not from this predicate. Answering false // here is what keeps the two mechanisms from both emitting the flag. -TEST(TargetSupportsFullStatic, PeTargetsDeferToContractTable) { - EXPECT_FALSE(tc::target_supports_full_static("x86_64-windows-gnu", true)); - EXPECT_FALSE(tc::target_supports_full_static("x86_64-windows-msvc", true)); - EXPECT_FALSE(tc::target_supports_full_static("x86_64-w64-mingw32", true)); -} +UTest pe_targets_defer_to_contract_table{"TargetSupportsFullStatic/PeTargetsDeferToContractTable", [] { + expect(!tc::target_supports_full_static("x86_64-windows-gnu", true)); + expect(!tc::target_supports_full_static("x86_64-windows-msvc", true)); + expect(!tc::target_supports_full_static("x86_64-w64-mingw32", true)); +}}; // ── Host target (empty triple) ────────────────────────────────────────────── // Empty means "build for this machine", so the host capability IS the answer. // This is the one case where the host constant remains correct. -TEST(TargetSupportsFullStatic, EmptyTripleFallsBackToHost) { - EXPECT_TRUE(tc::target_supports_full_static("", true)); - EXPECT_FALSE(tc::target_supports_full_static("", false)); -} +UTest empty_triple_falls_back_to_host{"TargetSupportsFullStatic/EmptyTripleFallsBackToHost", [] { + expect(bool(tc::target_supports_full_static("", true))); + expect(!tc::target_supports_full_static("", false)); +}}; // An unparseable triple must not silently become "static" — fall back to the // host answer rather than guessing from a substring. -TEST(TargetSupportsFullStatic, UnknownTripleFallsBackToHost) { - EXPECT_TRUE(tc::target_supports_full_static("wasm32-unknown-unknown", true)); - EXPECT_FALSE(tc::target_supports_full_static("wasm32-unknown-unknown", false)); - EXPECT_FALSE(tc::target_supports_full_static("not-a-triple", false)); -} +UTest unknown_triple_falls_back_to_host{"TargetSupportsFullStatic/UnknownTripleFallsBackToHost", [] { + expect(bool(tc::target_supports_full_static("wasm32-unknown-unknown", true))); + expect(!tc::target_supports_full_static("wasm32-unknown-unknown", false)); + expect(!tc::target_supports_full_static("not-a-triple", false)); +}}; } // namespace diff --git a/tests/unit/test_target_xlings_axis.cpp b/tests/unit/test_target_xlings_axis.cpp index bcfffe701..a795980a7 100644 --- a/tests/unit/test_target_xlings_axis.cpp +++ b/tests/unit/test_target_xlings_axis.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.build.prepare; +using namespace mcpplibs::mtest; + // SPEC-004 §4 — the tool plane has TWO resolution axes, and this file is the // measurement that tells them apart. // @@ -50,7 +52,7 @@ bool has(const std::vector& v, std::string_view a) { // leg had already merged would report the union and pass either way. mcpp::manifest::Manifest merged_for(std::string_view triple) { auto m = mcpp::manifest::parse_string(kSrc); - EXPECT_TRUE(m.has_value()); + expect(m.has_value()); if (!m) return {}; mcpp::build::merge_conditional_config(*m, cfgpred::context_for(triple)); return *m; @@ -58,33 +60,33 @@ mcpp::manifest::Manifest merged_for(std::string_view triple) { } // namespace -TEST(TargetXlingsAxis, SelectorDecidesWhichEntriesApply) { +UTest selector_decides_which_entries_apply{"TargetXlingsAxis/SelectorDecidesWhichEntriesApply", [] { auto lin = merged_for("x86_64-unknown-linux-gnu"); auto win = merged_for("x86_64-pc-windows-gnu"); // The host axis is the SAME on both legs. It is not conditional on the // target and must not become so: `xim:dpcpp` is a compiler that runs here // no matter what it is asked to emit. - EXPECT_TRUE(has(lin.xlings.deps, "xim:hosttool@1.0.0")); - EXPECT_TRUE(has(win.xlings.deps, "xim:hosttool@1.0.0")); + expect(bool(has(lin.xlings.deps, "xim:hosttool@1.0.0"))); + expect(bool(has(win.xlings.deps, "xim:hosttool@1.0.0"))); // The target axis follows the target, and nothing else in this test does. - EXPECT_TRUE (has(lin.xlings.deps, "xim:linuxonly@9.9.9")); - EXPECT_FALSE(has(lin.xlings.deps, "xim:winonly@8.8.8")); - EXPECT_TRUE (has(win.xlings.deps, "xim:winonly@8.8.8")); - EXPECT_FALSE(has(win.xlings.deps, "xim:linuxonly@9.9.9")); + expect(bool(has(lin.xlings.deps, "xim:linuxonly@9.9.9"))); + expect(!has(lin.xlings.deps, "xim:winonly@8.8.8")); + expect(bool(has(win.xlings.deps, "xim:winonly@8.8.8"))); + expect(!has(win.xlings.deps, "xim:linuxonly@9.9.9")); // The pin reaches the materialised resolution layer, keyed the way the // host axis keys it. An entry installed without its pin resolves to // whatever the machine already has, which is a different package on a // different day. auto pin = win.xlings.workspace.find("winonly"); - ASSERT_NE(pin, win.xlings.workspace.end()); - EXPECT_EQ(pin->second, "xim:8.8.8"); - EXPECT_FALSE(win.xlings.workspace.contains("linuxonly")); -} + expect(fatal(ne(pin, win.xlings.workspace.end()))); + expect(eq(pin->second, "xim:8.8.8")); + expect(!win.xlings.workspace.contains("linuxonly")); +}}; -TEST(TargetXlingsAxis, GateAndSelectorCompose) { +UTest gate_and_selector_compose{"TargetXlingsAxis/GateAndSelectorCompose", [] { // `[target..feature-xlings.]` is a gate INSIDE a condition, the // same composition `[target..feature-deps.]` already had (#359). // The feature is registered unconditionally — whether the target matches @@ -94,19 +96,19 @@ TEST(TargetXlingsAxis, GateAndSelectorCompose) { auto lin = merged_for("x86_64-unknown-linux-gnu"); auto win = merged_for("x86_64-pc-windows-gnu"); - EXPECT_TRUE(lin.featuresMap.contains("gate")); - EXPECT_TRUE(win.featuresMap.contains("gate")); + expect(lin.featuresMap.contains("gate")); + expect(win.featuresMap.contains("gate")); auto lg = lin.xlings.featureDeps.find("gate"); - EXPECT_TRUE(lg == lin.xlings.featureDeps.end() || lg->second.empty()); + expect(lg == lin.xlings.featureDeps.end() || lg->second.empty()); auto wg = win.xlings.featureDeps.find("gate"); - ASSERT_NE(wg, win.xlings.featureDeps.end()); - EXPECT_TRUE(has(wg->second, "xim:gatedwin@7.7.7")); - EXPECT_EQ(win.xlings.featurePins.at("xim:gatedwin@7.7.7"), "xim:7.7.7"); -} + expect(fatal(ne(wg, win.xlings.featureDeps.end()))); + expect(bool(has(wg->second, "xim:gatedwin@7.7.7"))); + expect(eq(win.xlings.featurePins.at("xim:gatedwin@7.7.7"), "xim:7.7.7")); +}}; -TEST(TargetXlingsAxis, ConditionWrittenTwiceIsRefused) { +UTest condition_written_twice_is_refused{"TargetXlingsAxis/ConditionWrittenTwiceIsRefused", [] { // Platform keys inside a value under a selector say a second time what the // selector already said. Refused rather than resolved: two statements of // one fact can disagree, and whichever one loses does so in silence. @@ -118,16 +120,16 @@ version = "0.1.0" [target.'cfg(os = "linux")'.xlings.workspace] "xim:tool" = { linux = "1.0.0", macosx = "2.0.0" } )"); - ASSERT_FALSE(m.has_value()); + expect(fatal(!m.has_value())); auto msg = m.error().format(); - EXPECT_NE(msg.find("xim:tool"), std::string::npos) << msg; - EXPECT_NE(msg.find("platform keys"), std::string::npos) << msg; + expect(ne(msg.find("xim:tool"), std::string::npos)) << msg; + expect(ne(msg.find("platform keys"), std::string::npos)) << msg; // The message must name the OUTER selector, because that is the half the // author has to delete, and it is not the half they are looking at. - EXPECT_NE(msg.find("cfg(os = \"linux\")"), std::string::npos) << msg; -} + expect(ne(msg.find("cfg(os = \"linux\")"), std::string::npos)) << msg; +}}; -TEST(TargetXlingsAxis, SubosIsNotConditionalOnATarget) { +UTest subos_is_not_conditional_on_a_target{"TargetXlingsAxis/SubosIsNotConditionalOnATarget", [] { // A project has one environment, not one per target. Refused rather than // ignored: a silently dropped environment is the failure #531 was filed // for. @@ -139,12 +141,12 @@ version = "0.1.0" [target.'cfg(os = "linux")'.xlings] subos = "3" )"); - ASSERT_FALSE(m.has_value()); + expect(fatal(!m.has_value())); auto msg = m.error().format(); - EXPECT_NE(msg.find("subos"), std::string::npos) << msg; -} + expect(ne(msg.find("subos"), std::string::npos)) << msg; +}}; -TEST(TargetXlingsAxis, HostAxisKeepsItsPlatformKeys) { +UTest host_axis_keeps_its_platform_keys{"TargetXlingsAxis/HostAxisKeepsItsPlatformKeys", [] { // V4, at unit scale: the existing spelling is untouched. The value object // under a TOP-LEVEL `[xlings]` entry is not legacy — it is how the host // axis is written, and it stays resolved against the host. @@ -156,27 +158,27 @@ version = "0.1.0" [xlings.workspace] "xim:tool" = { linux = "1.0.0", macosx = "2.0.0", windows = "3.0.0", default = "4.0.0" } )"); - ASSERT_TRUE(m.has_value()); + expect(fatal(m.has_value())); const auto& pin = m->xlings.workspace.at("tool"); #if defined(__linux__) - EXPECT_EQ(pin, "xim:1.0.0"); - EXPECT_TRUE(has(m->xlings.deps, "xim:tool@1.0.0")); + expect(eq(pin, "xim:1.0.0")); + expect(bool(has(m->xlings.deps, "xim:tool@1.0.0"))); #elif defined(__APPLE__) - EXPECT_EQ(pin, "xim:2.0.0"); - EXPECT_TRUE(has(m->xlings.deps, "xim:tool@2.0.0")); + expect(eq(pin, "xim:2.0.0")); + expect(bool(has(m->xlings.deps, "xim:tool@2.0.0"))); #elif defined(_WIN32) - EXPECT_EQ(pin, "xim:3.0.0"); - EXPECT_TRUE(has(m->xlings.deps, "xim:tool@3.0.0")); + expect(eq(pin, "xim:3.0.0")); + expect(bool(has(m->xlings.deps, "xim:tool@3.0.0"))); #else - EXPECT_EQ(pin, "xim:4.0.0"); - EXPECT_TRUE(has(m->xlings.deps, "xim:tool@4.0.0")); + expect(eq(pin, "xim:4.0.0")); + expect(bool(has(m->xlings.deps, "xim:tool@4.0.0"))); #endif // And it is NOT reported as a conditional section: the host axis resolves // at parse time and leaves nothing for the merge to do. - EXPECT_TRUE(m->conditionalConfigs.empty()); -} + expect(m->conditionalConfigs.empty()); +}}; -TEST(TargetXlingsAxis, BothAxesNamingOnePackageResolveToOneInstall) { +UTest both_axes_naming_one_package_resolve_to_one_install{"TargetXlingsAxis/BothAxesNamingOnePackageResolveToOneInstall", [] { // `xim:glibc` and `xim:glibc@2.40` are two addresses for ONE install. // Keeping both would ask xlings for the same package at two versions, and // that is not a build that fails — it is a build whose environment depends @@ -192,19 +194,19 @@ version = "0.1.0" [target.'cfg(os = "linux")'.xlings.workspace] "xim:glibc" = "2.40" )"); - ASSERT_TRUE(m.has_value()); + expect(fatal(m.has_value())); mcpp::build::merge_conditional_config( *m, cfgpred::context_for("x86_64-unknown-linux-gnu")); auto named = std::ranges::count_if(m->xlings.deps, [](const std::string& a) { return mcpp::manifest::parse_address(a).target == "glibc"; }); - EXPECT_EQ(named, 1); - EXPECT_TRUE(has(m->xlings.deps, "xim:glibc@2.40")); - EXPECT_EQ(m->xlings.workspace.at("glibc"), "xim:2.40"); -} + expect(eq(named, 1)); + expect(bool(has(m->xlings.deps, "xim:glibc@2.40"))); + expect(eq(m->xlings.workspace.at("glibc"), "xim:2.40")); +}}; -TEST(TargetXlingsAxis, ABareTripleSelectorIsASelectorToo) { +UTest a_bare_triple_selector_is_a_selector_too{"TargetXlingsAxis/ABareTripleSelectorIsASelectorToo", [] { // `[target..xlings.workspace]` is the other spelling of a selector, // and it was reached by the same dispatcher rather than by the // `[target.]` handler that owns `toolchain` and `runner`. Asserted @@ -219,14 +221,14 @@ version = "0.1.0" "xim:tool" = "1.0.0" )"; auto lin = mcpp::manifest::parse_string(src); - ASSERT_TRUE(lin.has_value()); + expect(fatal(lin.has_value())); mcpp::build::merge_conditional_config( *lin, cfgpred::context_for("x86_64-unknown-linux-gnu")); - EXPECT_TRUE(has(lin->xlings.deps, "xim:tool@1.0.0")); + expect(bool(has(lin->xlings.deps, "xim:tool@1.0.0"))); auto other = mcpp::manifest::parse_string(src); - ASSERT_TRUE(other.has_value()); + expect(fatal(other.has_value())); mcpp::build::merge_conditional_config( *other, cfgpred::context_for("aarch64-unknown-linux-gnu")); - EXPECT_FALSE(has(other->xlings.deps, "xim:tool@1.0.0")); -} + expect(!has(other->xlings.deps, "xim:tool@1.0.0")); +}}; diff --git a/tests/unit/test_targetside.cpp b/tests/unit/test_targetside.cpp index 36a699f4e..6f1007db9 100644 --- a/tests/unit/test_targetside.cpp +++ b/tests/unit/test_targetside.cpp @@ -12,11 +12,13 @@ // from inside a 7000-line translation unit and had zero test coverage, because // asserting it required running a whole build. Nothing here runs a build. -#include import std; +import mcpplibs.mtest; import mcpp.targetside; +using namespace mcpplibs::mtest; + namespace ts = mcpp::targetside; namespace { @@ -48,70 +50,70 @@ ts::Provider provider(std::string name, std::string version, // ── The capability grammar ─────────────────────────────────────────────────── -TEST(TargetSideCapability, ThreeLayerNamesAreAccepted) { +UTest three_layer_names_are_accepted{"TargetSideCapability/ThreeLayerNamesAreAccepted", [] { for (auto [entry, layer] : std::initializer_list< std::pair>{ {"mcpp:kernel-abi", ts::CapLayer::KernelAbi}, {"mcpp:c-abi", ts::CapLayer::CAbi}, {"mcpp:c++-abi", ts::CapLayer::CxxAbi}}) { auto r = ts::parse_capability(entry); - ASSERT_TRUE(r.has_value()) << entry; - ASSERT_TRUE(r->has_value()) << entry; - EXPECT_EQ((*r)->layer, layer) << entry; - EXPECT_TRUE((*r)->interfaceName.empty()) << entry; + expect(fatal(r.has_value())) << entry; + expect(fatal(r->has_value())) << entry; + expect(eq((*r)->layer, layer)) << entry; + expect((*r)->interfaceName.empty()) << entry; } -} +}}; -TEST(TargetSideCapability, InterfaceNameIsCarriedByTheDeclaration) { +UTest interface_name_is_carried_by_the_declaration{"TargetSideCapability/InterfaceNameIsCarriedByTheDeclaration", [] { auto r = ts::parse_capability("mcpp:kernel-abi=openkal"); - ASSERT_TRUE(r.has_value()); - ASSERT_TRUE(r->has_value()); - EXPECT_EQ((*r)->layer, ts::CapLayer::KernelAbi); - EXPECT_EQ((*r)->interfaceName, "openkal"); -} + expect(fatal(r.has_value())); + expect(fatal(r->has_value())); + expect(eq((*r)->layer, ts::CapLayer::KernelAbi)); + expect(eq((*r)->interfaceName, "openkal")); +}}; // The engine must not reject names it does not own. `provides` serves the // feature system too, and a closed set over the whole array would break the // allocator selection that already ships. -TEST(TargetSideCapability, NamesOutsideTheReservedPrefixPassThrough) { +UTest names_outside_the_reserved_prefix_pass_through{"TargetSideCapability/NamesOutsideTheReservedPrefixPassThrough", [] { for (std::string_view entry : {"freestanding-allocator", "hosted-standard-library", "anything-a-package-invents"}) { auto r = ts::parse_capability(entry); - ASSERT_TRUE(r.has_value()) << entry; - EXPECT_FALSE(r->has_value()) << entry; + expect(fatal(r.has_value())) << entry; + expect(!r->has_value()) << entry; } -} +}}; // And the point of having a prefix at all: inside it, a typo is an error rather // than a silently disabled behaviour. -TEST(TargetSideCapability, UnknownNameInsideTheReservedPrefixIsAnError) { +UTest unknown_name_inside_the_reserved_prefix_is_an_error{"TargetSideCapability/UnknownNameInsideTheReservedPrefixIsAnError", [] { for (std::string_view entry : {"mcpp:kernel_abi", // underscore, not hyphen "mcpp:cabi", "mcpp:c++abi", "mcpp:target-system"}) { auto r = ts::parse_capability(entry); - EXPECT_FALSE(r.has_value()) << entry; + expect(!r.has_value()) << entry; if (!r.has_value()) - EXPECT_NE(r.error().find("mcpp:kernel-abi"), std::string::npos) + expect(ne(r.error().find("mcpp:kernel-abi"), std::string::npos)) << "the diagnostic must list the layers that do exist"; } -} +}}; // ── The resolution table ───────────────────────────────────────────────────── -TEST(TargetSideResolve, TraditionalStackTakesEveryLayerFromThePayload) { +UTest traditional_stack_takes_every_layer_from_the_payload{"TargetSideResolve/TraditionalStackTakesEveryLayerFromThePayload", [] { auto r = ts::resolve(payload_linux()); - EXPECT_EQ(r.kernelAbi.origin, ts::Origin::Payload); - EXPECT_EQ(r.cAbi.origin, ts::Origin::Payload); - EXPECT_EQ(r.cxx.origin, ts::Origin::Payload); - EXPECT_EQ(r.kernelAbi.interfaceName, "linux"); - EXPECT_EQ(r.cAbi.interfaceName, "glibc"); - EXPECT_EQ(r.cxx.interfaceName, "libc++"); - EXPECT_FALSE(r.system_from_graph()); -} - -TEST(TargetSideResolve, OpenkalCxxTakesEveryLayerFromTheGraph) { + expect(eq(r.kernelAbi.origin, ts::Origin::Payload)); + expect(eq(r.cAbi.origin, ts::Origin::Payload)); + expect(eq(r.cxx.origin, ts::Origin::Payload)); + expect(eq(r.kernelAbi.interfaceName, "linux")); + expect(eq(r.cAbi.interfaceName, "glibc")); + expect(eq(r.cxx.interfaceName, "libc++")); + expect(!r.system_from_graph()); +}}; + +UTest openkal_cxx_takes_every_layer_from_the_graph{"TargetSideResolve/OpenkalCxxTakesEveryLayerFromTheGraph", [] { auto in = payload_linux(); in.llvmTriple = "arm64-apple-macos14.0"; in.targetOs = "macos"; @@ -120,14 +122,14 @@ TEST(TargetSideResolve, OpenkalCxxTakesEveryLayerFromTheGraph) { in.cxxAbi = provider("openkal-llvm-runtime", "0.1.0", "libc++", /*stdModule=*/true); auto r = ts::resolve(in); - EXPECT_EQ(r.kernelAbi.origin, ts::Origin::Graph); - EXPECT_EQ(r.cAbi.origin, ts::Origin::Graph); - EXPECT_EQ(r.cxx.origin, ts::Origin::Graph); - EXPECT_EQ(r.kernelAbi.impl, "openkal-macos@0.3.1"); - EXPECT_EQ(r.cAbi.impl, "openkal-musl@0.3.1"); - EXPECT_FALSE(r.cxx.subset) << "a package that declares a std module supplies the whole library"; - EXPECT_TRUE(r.system_from_graph()); -} + expect(eq(r.kernelAbi.origin, ts::Origin::Graph)); + expect(eq(r.cAbi.origin, ts::Origin::Graph)); + expect(eq(r.cxx.origin, ts::Origin::Graph)); + expect(eq(r.kernelAbi.impl, "openkal-macos@0.3.1")); + expect(eq(r.cAbi.impl, "openkal-musl@0.3.1")); + expect(!r.cxx.subset) << "a package that declares a std module supplies the whole library"; + expect(bool(r.system_from_graph())); +}}; // THE CASE THAT WAS UNREPRESENTABLE, AND THE MEASURED DEFECT. // @@ -136,7 +138,7 @@ TEST(TargetSideResolve, OpenkalCxxTakesEveryLayerFromTheGraph) { // read as "the target side is not from the graph", the payload's own libc++ // stayed on the link line, and a macOS cross ended in // `libc++.so: unhandled file type`. -TEST(TargetSideResolve, PureCOverOpenkalStillHasItsSystemFromTheGraph) { +UTest pure_c_over_openkal_still_has_its_system_from_the_graph{"TargetSideResolve/PureCOverOpenkalStillHasItsSystemFromTheGraph", [] { auto in = payload_linux(); in.llvmTriple = "arm64-apple-macos14.0"; in.targetOs = "macos"; @@ -145,47 +147,47 @@ TEST(TargetSideResolve, PureCOverOpenkalStillHasItsSystemFromTheGraph) { // no cxxAbi provider auto r = ts::resolve(in); - EXPECT_EQ(r.cxx.origin, ts::Origin::None) + expect(eq(r.cxx.origin, ts::Origin::None)) << "a C program needs no C++ runtime, and that is not a failure to find one"; - EXPECT_TRUE(r.system_from_graph()) + expect(bool(r.system_from_graph())) << "the system still comes from the graph, which is the whole defect"; - EXPECT_NE(r.cxx.origin, ts::Origin::Payload) + expect(ne(r.cxx.origin, ts::Origin::Payload)) << "the payload's C++ runtime must not be selected over a graph C library"; -} +}}; // Only the platform implementation, and nothing above it. -TEST(TargetSideResolve, RawOpenkalHasOnlyAKernelAbi) { +UTest raw_openkal_has_only_a_kernel_abi{"TargetSideResolve/RawOpenkalHasOnlyAKernelAbi", [] { auto in = payload_linux(); in.kernelAbi = provider("openkal-linux", "0.5.1", "openkal"); in.sysrootDeclaredEmpty = true; auto r = ts::resolve(in); - EXPECT_EQ(r.kernelAbi.origin, ts::Origin::Graph); - EXPECT_TRUE(r.cAbi.absent()); - EXPECT_TRUE(r.cxx.absent()); -} + expect(eq(r.kernelAbi.origin, ts::Origin::Graph)); + expect(bool(r.cAbi.absent())); + expect(bool(r.cxx.absent())); +}}; -TEST(TargetSideResolve, BareMetalWithPicolibcHasNoKernelAbi) { +UTest bare_metal_with_picolibc_has_no_kernel_abi{"TargetSideResolve/BareMetalWithPicolibcHasNoKernelAbi", [] { ts::Inputs in; in.llvmTriple = "riscv64-none-elf"; in.freestandingTarget = true; in.sysrootXpkg = "xim:picolibc-riscv@1.8.12"; auto r = ts::resolve(in); - EXPECT_TRUE(r.kernelAbi.absent()) + expect(bool(r.kernelAbi.absent())) << "a bare machine has no kernel, and saying so is the information"; - EXPECT_EQ(r.cAbi.origin, ts::Origin::Xpkg); + expect(eq(r.cAbi.origin, ts::Origin::Xpkg)); // The package name, not a prettier form of it. Stripping the `-riscv` // suffix would mean the engine knows how this ecosystem names its // packages, which is exactly the knowledge this design keeps out of it. - EXPECT_EQ(r.cAbi.interfaceName, "picolibc-riscv"); - EXPECT_EQ(r.cAbi.impl, "xim:picolibc-riscv@1.8.12"); - EXPECT_TRUE(r.cxx.absent()); - EXPECT_FALSE(r.system_from_graph()); -} + expect(eq(r.cAbi.interfaceName, "picolibc-riscv")); + expect(eq(r.cAbi.impl, "xim:picolibc-riscv@1.8.12")); + expect(bool(r.cxx.absent())); + expect(!r.system_from_graph()); +}}; // THE MIXED CASE. It already ships, and no single boolean can express it. -TEST(TargetSideResolve, PrebuiltCLibraryUnderAGraphSuppliedCxxSubset) { +UTest prebuilt_c_library_under_a_graph_supplied_cxx_subset{"TargetSideResolve/PrebuiltCLibraryUnderAGraphSuppliedCxxSubset", [] { ts::Inputs in; in.llvmTriple = "riscv64-none-elf"; in.freestandingTarget = true; @@ -193,33 +195,33 @@ TEST(TargetSideResolve, PrebuiltCLibraryUnderAGraphSuppliedCxxSubset) { in.cxxAbi = provider("std-freestanding", "0.5.0", "freestanding subset"); auto r = ts::resolve(in); - EXPECT_EQ(r.cAbi.origin, ts::Origin::Xpkg) << "prebuilt"; - EXPECT_EQ(r.cxx.origin, ts::Origin::Graph) << "composed at build time"; - EXPECT_TRUE(r.cxx.subset) << "no std module declared, so the library is a subset"; -} + expect(eq(r.cAbi.origin, ts::Origin::Xpkg)) << "prebuilt"; + expect(eq(r.cxx.origin, ts::Origin::Graph)) << "composed at build time"; + expect(bool(r.cxx.subset)) << "no std module declared, so the library is a subset"; +}}; // A hosted target whose C library is the payload's (a located SDK, or glibc) // while a package supplies the whole standard library: the iOS rows over // `llvm.libcxx` (mcpp#630). The C++ layer is the graph's and is not a subset, // and the C library stays the payload's; nothing about one decides the other. -TEST(TargetSideResolve, PrebuiltCLibraryUnderAGraphSuppliedLibcxx) { +UTest prebuilt_c_library_under_a_graph_supplied_libcxx{"TargetSideResolve/PrebuiltCLibraryUnderAGraphSuppliedLibcxx", [] { auto in = payload_linux(); in.cxxAbi = provider("libcxx", "22.1.8.1", "libc++", /*stdModule=*/true); auto r = ts::resolve(in); - EXPECT_EQ(r.cAbi.origin, ts::Origin::Payload) << "the SDK's or glibc's, prebuilt"; - EXPECT_TRUE(r.cAbi.prebuilt()); - EXPECT_EQ(r.cxx.origin, ts::Origin::Graph); - EXPECT_TRUE(r.cxx.fromGraph()); - EXPECT_FALSE(r.cxx.subset) << "a std module is declared, so this is the whole library"; - EXPECT_FALSE(r.system_from_graph()) << "the system is still the payload's"; -} + expect(eq(r.cAbi.origin, ts::Origin::Payload)) << "the SDK's or glibc's, prebuilt"; + expect(bool(r.cAbi.prebuilt())); + expect(eq(r.cxx.origin, ts::Origin::Graph)); + expect(bool(r.cxx.fromGraph())); + expect(!r.cxx.subset) << "a std module is declared, so this is the whole library"; + expect(!r.system_from_graph()) << "the system is still the payload's"; +}}; // The payload has no compiler runtime for this platform and the graph // declares none: the layer is absent, and the report says so rather than // naming the compiler's family for an archive that does not exist. With a // graph provider the same input resolves to the graph. -TEST(TargetSideResolve, APayloadWithoutACompilerRuntimeForThePlatform) { +UTest a_payload_without_a_compiler_runtime_for_the_platform{"TargetSideResolve/APayloadWithoutACompilerRuntimeForThePlatform", [] { auto in = payload_linux(); in.llvmTriple = "arm64-apple-ios18.0-simulator"; in.targetOs = "ios"; @@ -228,72 +230,72 @@ TEST(TargetSideResolve, APayloadWithoutACompilerRuntimeForThePlatform) { in.payloadCompilerRuntimeAbsent = true; auto absent = ts::resolve(in); - EXPECT_TRUE(absent.compilerRuntime.absent()); + expect(bool(absent.compilerRuntime.absent())); in.compilerRuntime = provider("compiler-rt-builtins", "22.1.8.3", "compiler-rt"); auto supplied = ts::resolve(in); - EXPECT_EQ(supplied.compilerRuntime.origin, ts::Origin::Graph); - EXPECT_FALSE(supplied.compilerRuntime.absent()); + expect(eq(supplied.compilerRuntime.origin, ts::Origin::Graph)); + expect(!supplied.compilerRuntime.absent()); // Negative direction: a payload that has the archive reports its own. in.compilerRuntime.reset(); in.payloadCompilerRuntimeAbsent = false; auto own = ts::resolve(in); - EXPECT_EQ(own.compilerRuntime.origin, ts::Origin::Payload); -} + expect(eq(own.compilerRuntime.origin, ts::Origin::Payload)); +}}; -TEST(TargetSideResolve, ZeroLibcTierHasNothingAtAll) { +UTest zero_libc_tier_has_nothing_at_all{"TargetSideResolve/ZeroLibcTierHasNothingAtAll", [] { ts::Inputs in; in.llvmTriple = "x86_64-none-elf"; in.freestandingTarget = true; in.sysrootDeclaredEmpty = true; auto r = ts::resolve(in); - EXPECT_TRUE(r.kernelAbi.absent()); - EXPECT_TRUE(r.cAbi.absent()); - EXPECT_TRUE(r.cxx.absent()); -} + expect(bool(r.kernelAbi.absent())); + expect(bool(r.cAbi.absent())); + expect(bool(r.cxx.absent())); +}}; // ── The layering rule ──────────────────────────────────────────────────────── // // An implementation must have been configured for the layer beneath it. The // resolver enforces it by construction; this asserts that the construction // really does, rather than that a later check catches it. -TEST(TargetSideResolve, PayloadCxxIsNotSelectedOverANonPayloadCLibrary) { +UTest payload_cxx_is_not_selected_over_a_non_payload_c_library{"TargetSideResolve/PayloadCxxIsNotSelectedOverANonPayloadCLibrary", [] { for (auto make : std::initializer_list>{ [] { auto in = payload_linux(); in.sysrootXpkg = "xim:picolibc-riscv@1.8.12"; return in; }, [] { auto in = payload_linux(); in.cAbi = provider("openkal-musl", "0.3.1", "musl"); return in; }}) { auto r = ts::resolve(make()); - EXPECT_NE(r.cxx.origin, ts::Origin::Payload); - EXPECT_EQ(ts::check_layering(r), std::nullopt) + expect(ne(r.cxx.origin, ts::Origin::Payload)); + expect(eq(ts::check_layering(r), std::nullopt)) << "the default path must not be able to construct the violation"; } -} +}}; -TEST(TargetSideResolve, TheLayeringRuleIsStatedForTheOverridePath) { +UTest the_layering_rule_is_stated_for_the_override_path{"TargetSideResolve/TheLayeringRuleIsStatedForTheOverridePath", [] { ts::TargetSide bad; bad.cAbi = { ts::Origin::Graph, "musl", "openkal-musl@0.3.1", false }; bad.cxx = { ts::Origin::Payload, "libc++", "xim-x-llvm@22.1.8", false }; auto why = ts::check_layering(bad); - ASSERT_TRUE(why.has_value()); - EXPECT_NE(why->find("never configured for this one"), std::string::npos) + expect(fatal(why.has_value())); + expect(ne(why->find("never configured for this one"), std::string::npos)) << "the diagnostic must state the reason, not merely the combination"; -} +}}; // ── The report ─────────────────────────────────────────────────────────────── -TEST(TargetSideReport, ShowsTheTranslatedTripleWhenItDiffers) { +UTest shows_the_translated_triple_when_it_differs{"TargetSideReport/ShowsTheTranslatedTripleWhenItDiffers", [] { auto in = payload_linux(); in.llvmTriple = "arm64-apple-macos14.0"; auto text = ts::format_report(ts::resolve(in), "aarch64-macos"); - EXPECT_NE(text.find("aarch64-macos → arm64-apple-macos14.0"), std::string::npos) + expect(ne(text.find("aarch64-macos → arm64-apple-macos14.0"), std::string::npos)) << "the translation is load bearing: the untranslated form emits a Mach-O " "whose MinVersion carries no platform"; -} +}}; -TEST(TargetSideReport, AbsentLayersReadAsADashRatherThanBeingOmitted) { +UTest absent_layers_read_as_a_dash_rather_than_being_omitted{"TargetSideReport/AbsentLayersReadAsADashRatherThanBeingOmitted", [] { ts::Inputs in; in.llvmTriple = "x86_64-none-elf"; in.freestandingTarget = true; @@ -301,38 +303,38 @@ TEST(TargetSideReport, AbsentLayersReadAsADashRatherThanBeingOmitted) { in.compilerFamily = "llvm"; auto text = ts::format_report(ts::resolve(in), "x86_64-none-elf", /*verbose=*/true); - EXPECT_NE(text.find("kernel-abi"), std::string::npos); - EXPECT_NE(text.find("c-abi"), std::string::npos); - EXPECT_NE(text.find("c++-abi"), std::string::npos); - EXPECT_NE(text.find("—"), std::string::npos); -} + expect(ne(text.find("kernel-abi"), std::string::npos)); + expect(ne(text.find("c-abi"), std::string::npos)); + expect(ne(text.find("c++-abi"), std::string::npos)); + expect(ne(text.find("—"), std::string::npos)); +}}; -TEST(TargetSideReport, NamesInterfaceAndImplementationSeparately) { +UTest names_interface_and_implementation_separately{"TargetSideReport/NamesInterfaceAndImplementationSeparately", [] { auto in = payload_linux(); in.kernelAbi = provider("openkal-opensbi", "0.1.0", "openkal"); auto text = ts::format_report(ts::resolve(in), "riscv64-none-elf"); - EXPECT_NE(text.find("openkal"), std::string::npos); - EXPECT_NE(text.find("openkal-opensbi@0.1.0"), std::string::npos) + expect(ne(text.find("openkal"), std::string::npos)); + expect(ne(text.find("openkal-opensbi@0.1.0"), std::string::npos)) << "four packages answer to one interface name; collapsing them would " "hide why one source reaches four machines"; - EXPECT_NE(text.find("graph"), std::string::npos); -} + expect(ne(text.find("graph"), std::string::npos)); +}}; // ── What earns a line ──────────────────────────────────────────────────────── // // A zero-configuration build resolves every layer from one compiler payload, // and five lines reading `(payload)` answer a question nobody asked. What earns // a line is a layer that came from somewhere else. -TEST(TargetSideReport, AZeroConfigurationBuildPrintsOnlyTheTarget) { +UTest a_zero_configuration_build_prints_only_the_target{"TargetSideReport/AZeroConfigurationBuildPrintsOnlyTheTarget", [] { auto in = payload_linux(); in.compilerFamily = "llvm"; in.compilerVersion = "22.1.8"; auto text = ts::format_report(ts::resolve(in), "x86_64-linux-gnu"); - EXPECT_EQ(text.find('\n'), std::string::npos) + expect(eq(text.find('\n'), std::string::npos)) << "every layer came from the payload, so none of them is news:\n" << text; -} +}}; -TEST(TargetSideReport, VerbosePrintsAllFiveIncludingThePayloadOnes) { +UTest verbose_prints_all_five_including_the_payload_ones{"TargetSideReport/VerbosePrintsAllFiveIncludingThePayloadOnes", [] { auto in = payload_linux(); in.compilerFamily = "llvm"; in.compilerVersion = "22.1.8"; @@ -340,34 +342,34 @@ TEST(TargetSideReport, VerbosePrintsAllFiveIncludingThePayloadOnes) { /*verbose=*/true); for (auto* label : {"compiler", "compiler-runtime", "kernel-abi", "c-abi", "c++-abi"}) - EXPECT_NE(text.find(label), std::string::npos) << label; -} + expect(ne(text.find(label), std::string::npos)) << label; +}}; // An absent layer is a statement, and whether the stack is shown at all must not // depend on where that statement sits among the rows. A bare-metal build has an // absent kernel interface ABOVE a prebuilt C library; deciding per row swallowed // the first and printed the second. -TEST(TargetSideReport, AnAbsentLayerAboveAPrintedOneIsStillPrinted) { +UTest an_absent_layer_above_a_printed_one_is_still_printed{"TargetSideReport/AnAbsentLayerAboveAPrintedOneIsStillPrinted", [] { ts::Inputs in; in.llvmTriple = "riscv64-none-elf"; in.freestandingTarget = true; in.compilerFamily = "llvm"; in.sysrootXpkg = "xim:picolibc-riscv@1.8.12"; auto text = ts::format_report(ts::resolve(in), "riscv64-none-elf"); - EXPECT_NE(text.find("c-abi"), std::string::npos) << text; - EXPECT_NE(text.find("kernel-abi"), std::string::npos) + expect(ne(text.find("c-abi"), std::string::npos)) << text; + expect(ne(text.find("kernel-abi"), std::string::npos)) << "the kernel interface is absent, which is the information:\n" << text; -} +}}; -TEST(TargetSideReport, OnlyTheLayersThatCameFromElsewhereEarnALine) { +UTest only_the_layers_that_came_from_elsewhere_earn_a_line{"TargetSideReport/OnlyTheLayersThatCameFromElsewhereEarnALine", [] { auto in = payload_linux(); in.compilerFamily = "llvm"; in.cxxAbi = provider("openkal-llvm-runtime", "0.1.1", "libc++"); auto text = ts::format_report(ts::resolve(in), "x86_64-linux-gnu"); - EXPECT_NE(text.find("c++-abi"), std::string::npos); - EXPECT_EQ(text.find("kernel-abi"), std::string::npos) + expect(ne(text.find("c++-abi"), std::string::npos)); + expect(eq(text.find("kernel-abi"), std::string::npos)) << "the kernel interface is the payload's, which is not news"; -} +}}; // ── The five layers ────────────────────────────────────────────────────────── @@ -375,81 +377,81 @@ TEST(TargetSideReport, OnlyTheLayersThatCameFromElsewhereEarnALine) { // one. macOS has none, and falling back to `glibc` named a library that does not // exist on the platform — invisible while the report printed only the layers a // build had something to say about. -TEST(TargetSideResolve, ThePayloadCLibraryIsNamedForItsPlatform) { +UTest the_payload_c_library_is_named_for_its_platform{"TargetSideResolve/ThePayloadCLibraryIsNamedForItsPlatform", [] { auto mac = payload_linux(); mac.targetOs = "macos"; mac.targetEnv = ""; - EXPECT_EQ(ts::resolve(mac).cAbi.interfaceName, "libSystem"); + expect(eq(ts::resolve(mac).cAbi.interfaceName, "libSystem")); auto win = payload_linux(); win.targetOs = "windows"; win.targetEnv = ""; - EXPECT_EQ(ts::resolve(win).cAbi.interfaceName, "ucrt"); + expect(eq(ts::resolve(win).cAbi.interfaceName, "ucrt")); // #540: the layer names the LIBRARY, never the triple's env segment. `musl` // is both spellings at once and hid the difference; `gnu` is not the name of // any C library, and on Windows it does not even mean the same one. - EXPECT_EQ(ts::resolve(payload_linux()).cAbi.interfaceName, "glibc") + expect(eq(ts::resolve(payload_linux()).cAbi.interfaceName, "glibc")) << "the env segment `gnu` asks for a C library; the layer answers glibc"; auto winGnu = payload_linux(); winGnu.targetOs = "windows"; winGnu.targetEnv = "gnu"; - EXPECT_EQ(ts::resolve(winGnu).cAbi.interfaceName, "ucrt") + expect(eq(ts::resolve(winGnu).cAbi.interfaceName, "ucrt")) << "on Windows `-gnu` names the MinGW toolchain flavour, not glibc"; auto musl = payload_linux(); musl.targetEnv = "musl"; - EXPECT_EQ(ts::resolve(musl).cAbi.interfaceName, "musl") + expect(eq(ts::resolve(musl).cAbi.interfaceName, "musl")) << "the one env segment that IS a C library name is unchanged"; -} +}}; // The rename above must not turn every ordinary `-gnu` build into a reported // request mismatch: the request keeps the triple's spelling by definition // (docs/specs/target-side.md §3.4) and the answer now names the library. -TEST(TargetSideResolve, TheEnvRequestIsSatisfiedByTheLibraryItNames) { - EXPECT_TRUE(ts::c_abi_request_satisfied("gnu", "glibc")); - EXPECT_TRUE(ts::c_abi_request_satisfied("gnu", "ucrt")); - EXPECT_TRUE(ts::c_abi_request_satisfied("musl", "musl")); - EXPECT_TRUE(ts::c_abi_request_satisfied("glibc", "gnu")); - EXPECT_FALSE(ts::c_abi_request_satisfied("gnu", "musl")); - EXPECT_FALSE(ts::c_abi_request_satisfied("musl", "glibc")); -} - -TEST(TargetSideResolve, TheCompilerIsALayerAndItIsAlwaysThePayloads) { +UTest the_env_request_is_satisfied_by_the_library_it_names{"TargetSideResolve/TheEnvRequestIsSatisfiedByTheLibraryItNames", [] { + expect(bool(ts::c_abi_request_satisfied("gnu", "glibc"))); + expect(bool(ts::c_abi_request_satisfied("gnu", "ucrt"))); + expect(bool(ts::c_abi_request_satisfied("musl", "musl"))); + expect(bool(ts::c_abi_request_satisfied("glibc", "gnu"))); + expect(!ts::c_abi_request_satisfied("gnu", "musl")); + expect(!ts::c_abi_request_satisfied("musl", "glibc")); +}}; + +UTest the_compiler_is_a_layer_and_it_is_always_the_payloads{"TargetSideResolve/TheCompilerIsALayerAndItIsAlwaysThePayloads", [] { auto in = payload_linux(); in.compilerFamily = "gcc"; in.compilerVersion = "16.1.0"; auto r = ts::resolve(in); - EXPECT_EQ(r.compiler.origin, ts::Origin::Payload); - EXPECT_EQ(r.compiler.interfaceName, "gcc"); - EXPECT_EQ(r.compiler.impl, "16.1.0"); -} + expect(eq(r.compiler.origin, ts::Origin::Payload)); + expect(eq(r.compiler.interfaceName, "gcc")); + expect(eq(r.compiler.impl, "16.1.0")); +}}; // The builtins are what a PURE C PROGRAM needs. A package supplies them only // when the payload's own are wrong for the target; absent from the graph means // the payload's, not absent. -TEST(TargetSideResolve, TheCompilerRuntimeDefaultsToTheCompilersOwn) { +UTest the_compiler_runtime_defaults_to_the_compilers_own{"TargetSideResolve/TheCompilerRuntimeDefaultsToTheCompilersOwn", [] { auto in = payload_linux(); in.compilerFamily = "llvm"; auto r = ts::resolve(in); - EXPECT_EQ(r.compilerRuntime.origin, ts::Origin::Payload); - EXPECT_EQ(r.compilerRuntime.interfaceName, "llvm"); + expect(eq(r.compilerRuntime.origin, ts::Origin::Payload)); + expect(eq(r.compilerRuntime.interfaceName, "llvm")); in.compilerRuntime = provider("openkal-llvm-runtime", "0.1.1", "compiler-rt"); auto g = ts::resolve(in); - EXPECT_EQ(g.compilerRuntime.origin, ts::Origin::Graph); - EXPECT_EQ(g.compilerRuntime.interfaceName, "compiler-rt"); -} + expect(eq(g.compilerRuntime.origin, ts::Origin::Graph)); + expect(eq(g.compilerRuntime.interfaceName, "compiler-rt")); +}}; -TEST(TargetSideCapability, TheCompilerIsALayerNoPackageMaySupply) { - EXPECT_FALSE(ts::layer_is_suppliable_by_package(ts::CapLayer::Compiler)); +UTest the_compiler_is_a_layer_no_package_may_supply{"TargetSideCapability/TheCompilerIsALayerNoPackageMaySupply", [] { + expect(!ts::layer_is_suppliable_by_package(ts::CapLayer::Compiler)); for (auto l : {ts::CapLayer::CompilerRuntime, ts::CapLayer::KernelAbi, ts::CapLayer::CAbi, ts::CapLayer::CxxAbi}) - EXPECT_TRUE(ts::layer_is_suppliable_by_package(l)); -} + expect(ts::layer_is_suppliable_by_package(l)); +}}; -TEST(TargetSideCapability, TheGrammarKnowsAllFiveLayers) { +UTest the_grammar_knows_all_five_layers{"TargetSideCapability/TheGrammarKnowsAllFiveLayers", [] { struct Row { const char* entry; ts::CapLayer layer; const char* iface; }; for (auto const& row : std::initializer_list{ {"mcpp:compiler=llvm", ts::CapLayer::Compiler, "llvm"}, @@ -458,22 +460,22 @@ TEST(TargetSideCapability, TheGrammarKnowsAllFiveLayers) { {"mcpp:c-abi=musl", ts::CapLayer::CAbi, "musl"}, {"mcpp:c++-abi=libc++", ts::CapLayer::CxxAbi, "libc++"}}) { auto parsed = ts::parse_capability(row.entry); - ASSERT_TRUE(parsed.has_value()) << row.entry; - ASSERT_TRUE(parsed->has_value()) << row.entry; - EXPECT_EQ((*parsed)->layer, row.layer) << row.entry; - EXPECT_EQ((*parsed)->interfaceName, row.iface) << row.entry; + expect(fatal(parsed.has_value())) << row.entry; + expect(fatal(parsed->has_value())) << row.entry; + expect(eq((*parsed)->layer, row.layer)) << row.entry; + expect(eq((*parsed)->interfaceName, row.iface)) << row.entry; } // Outside the reserved prefix the feature system owns the name. auto own = ts::parse_capability("freestanding-allocator"); - ASSERT_TRUE(own.has_value()); - EXPECT_FALSE(own->has_value()); + expect(fatal(own.has_value())); + expect(!own->has_value()); // Inside it, a misspelling is an error rather than a silently dead check. - EXPECT_FALSE(ts::parse_capability("mcpp:c_abi=musl").has_value()); -} + expect(!(ts::parse_capability("mcpp:c_abi=musl").has_value())); +}}; // ── The triple is a request; the target side is the fact ──────────────────── -TEST(TargetSideRequest, AFilledEnvSegmentStatesNothing) { +UTest a_filled_env_segment_states_nothing{"TargetSideRequest/AFilledEnvSegmentStatesNothing", [] { auto in = payload_linux(); in.compilerFamily = "llvm"; in.cAbi = provider("openkal-musl", "0.3.3", "musl"); @@ -481,14 +483,14 @@ TEST(TargetSideRequest, AFilledEnvSegmentStatesNothing) { // `--target x86_64-linux` — the project declined to name a C library, so // the parser's fill must not become a claim it can be held to. in.requestedCAbi.clear(); - EXPECT_EQ(ts::check_request(ts::resolve(in)), std::nullopt); -} + expect(eq(ts::check_request(ts::resolve(in)), std::nullopt)); +}}; // REPORTED, NOT REFUSED. The graph supplies the C library either way, so the // segment is ignored rather than violated and the artifact is identical with or // without it. Refusing was tried and broke every project spelling the host // target `x86_64-linux-gnu` — which is what `mcpp toolchain list` prints. -TEST(TargetSideRequest, AWrittenEnvSegmentThatDisagreesIsReported) { +UTest a_written_env_segment_that_disagrees_is_reported{"TargetSideRequest/AWrittenEnvSegmentThatDisagreesIsReported", [] { auto in = payload_linux(); in.compilerFamily = "llvm"; in.cAbi = provider("openkal-musl", "0.3.3", "musl"); @@ -496,23 +498,23 @@ TEST(TargetSideRequest, AWrittenEnvSegmentThatDisagreesIsReported) { in.requestFreeTarget = "x86_64-linux"; in.envAxis = ts::EnvAxis::CLibrary; auto why = ts::check_request(ts::resolve(in)); - ASSERT_TRUE(why.has_value()); - EXPECT_NE(why->find("`gnu`"), std::string::npos); - EXPECT_NE(why->find("musl"), std::string::npos); - EXPECT_NE(why->find("--target x86_64-linux"), std::string::npos) + expect(fatal(why.has_value())); + expect(ne(why->find("`gnu`"), std::string::npos)); + expect(ne(why->find("musl"), std::string::npos)); + expect(ne(why->find("--target x86_64-linux"), std::string::npos)) << "telling someone their target name is wrong is only useful once " "there is a right one to give them"; -} +}}; -TEST(TargetSideRequest, APrebuiltCLibraryIsWhatTheRequestSelected) { +UTest a_prebuilt_c_library_is_what_the_request_selected{"TargetSideRequest/APrebuiltCLibraryIsWhatTheRequestSelected", [] { auto in = payload_linux(); in.compilerFamily = "llvm"; in.requestedCAbi = "musl"; in.envAxis = ts::EnvAxis::CLibrary; // No graph supplier: the payload's C library IS the request's answer, so // there is nothing to contradict even when the names differ. - EXPECT_EQ(ts::check_request(ts::resolve(in)), std::nullopt); -} + expect(eq(ts::check_request(ts::resolve(in)), std::nullopt)); +}}; // On Windows the same segment names the OBJECT ABI — `gnu` is PE with the // GNU ABI, `msvc` is PE with Microsoft's — and both are compatible with more @@ -527,7 +529,7 @@ TEST(TargetSideRequest, APrebuiltCLibraryIsWhatTheRequestSelected) { // C library replaces. The second half is a name/fact disagreement of exactly // the shape this function reports, and it went unreported — while the identical // shape on Linux warned. -TEST(TargetSideRequest, TheObjectAbiAxisReportsTheCLibraryHalfToo) { +UTest the_object_abi_axis_reports_the_c_library_half_too{"TargetSideRequest/TheObjectAbiAxisReportsTheCLibraryHalfToo", [] { auto in = payload_linux(); in.targetOs = "windows"; in.compilerFamily = "llvm"; @@ -537,18 +539,18 @@ TEST(TargetSideRequest, TheObjectAbiAxisReportsTheCLibraryHalfToo) { in.envAxis = ts::EnvAxis::ObjectAbi; auto why = ts::check_request(ts::resolve(in)); - ASSERT_NE(why, std::nullopt); - EXPECT_NE(why->find("musl"), std::string::npos) << *why; + expect(fatal(ne(why, std::nullopt))); + if (auto&& failed = expect(ne(why->find("musl"), std::string::npos)); !failed) failed << *why; // And it says the ABI half was honoured, so the reader does not conclude // the object ABI changed as well. - EXPECT_NE(why->find("object ABI"), std::string::npos) << *why; - EXPECT_NE(why->find("--target x86_64-windows"), std::string::npos) << *why; -} + expect(ne(why->find("object ABI"), std::string::npos)) << *why; + if (auto&& failed = expect(ne(why->find("--target x86_64-windows"), std::string::npos)); !failed) failed << *why; +}}; // The object-FORMAT axis stays exempt, and not for symmetry: `elf` names no C // library on any platform, so "asks for the `elf` C ABI" would be nonsense // rather than merely noisy. -TEST(TargetSideRequest, TheObjectFormatAxisStaysExempt) { +UTest the_object_format_axis_stays_exempt{"TargetSideRequest/TheObjectFormatAxisStaysExempt", [] { auto in = payload_linux(); in.targetOs = "none"; in.compilerFamily = "llvm"; @@ -556,8 +558,8 @@ TEST(TargetSideRequest, TheObjectFormatAxisStaysExempt) { in.requestedCAbi = "elf"; in.requestFreeTarget = "riscv64-none"; in.envAxis = ts::EnvAxis::ObjectFormat; - EXPECT_EQ(ts::check_request(ts::resolve(in)), std::nullopt); -} + expect(eq(ts::check_request(ts::resolve(in)), std::nullopt)); +}}; // AND WHEN IT DOES NOT NAME ONE, THE REPORT SAYS WHAT IT DOES NAME. // @@ -568,7 +570,7 @@ TEST(TargetSideRequest, TheObjectFormatAxisStaysExempt) { // THE GLOSS IS NOW THE OBJECT-FORMAT AXIS ALONE. The object-ABI axis warns // instead (see `TheObjectAbiAxisReportsTheCLibraryHalfToo`), and leaving both // in place would state one finding twice — once as an aside, once as a warning. -TEST(TargetSideReport, TheObjectAbiAxisIsNoLongerGlossed) { +UTest the_object_abi_axis_is_no_longer_glossed{"TargetSideReport/TheObjectAbiAxisIsNoLongerGlossed", [] { auto in = payload_linux(); in.targetOs = "windows"; in.compilerFamily = "llvm"; @@ -578,12 +580,12 @@ TEST(TargetSideReport, TheObjectAbiAxisIsNoLongerGlossed) { in.envAxis = ts::EnvAxis::ObjectAbi; auto r = ts::format_report(ts::resolve(in), "x86_64-windows-gnu"); - EXPECT_EQ(r.find("not a C library"), std::string::npos) << r; -} + expect(eq(r.find("not a C library"), std::string::npos)) << r; +}}; // A bare-metal target's segment names the object FORMAT, and the same gloss // applies with a different noun. One enum, three platforms, no `#if`. -TEST(TargetSideReport, OnBareMetalTheSegmentNamesTheObjectFormat) { +UTest on_bare_metal_the_segment_names_the_object_format{"TargetSideReport/OnBareMetalTheSegmentNamesTheObjectFormat", [] { auto in = payload_linux(); in.targetOs = "none"; in.compilerFamily = "llvm"; @@ -593,16 +595,15 @@ TEST(TargetSideReport, OnBareMetalTheSegmentNamesTheObjectFormat) { in.envAxis = ts::EnvAxis::ObjectFormat; auto r = ts::format_report(ts::resolve(in), "riscv64-none-elf"); - EXPECT_NE(r.find("elf selects the object format, not a C library"), - std::string::npos) << r; -} + expect(ne(r.find("elf selects the object format, not a C library"), std::string::npos)) << r; +}}; // AND IT DOES NOT FIRE WHEN THE C LIBRARY CAME FROM A PAYLOAD. // // A payload C library IS what the triple selected — the triple is how it was // selected — so `gnu → ucrt` follows visibly and a gloss would be noise on // every ordinary Windows build. -TEST(TargetSideReport, NoGlossWhenTheTripleItselfChoseTheCLibrary) { +UTest no_gloss_when_the_triple_itself_chose_the_c_library{"TargetSideReport/NoGlossWhenTheTripleItselfChoseTheCLibrary", [] { auto in = payload_linux(); in.targetOs = "windows"; in.compilerFamily = "llvm"; @@ -612,13 +613,13 @@ TEST(TargetSideReport, NoGlossWhenTheTripleItselfChoseTheCLibrary) { // cAbi left as the payload's — not from the graph. auto r = ts::format_report(ts::resolve(in), "x86_64-windows-gnu"); - EXPECT_EQ(r.find("not a C library"), std::string::npos) << r; -} + expect(eq(r.find("not a C library"), std::string::npos)) << r; +}}; // On Linux the segment DOES name a C library, so there is nothing to gloss — // the disagreement is reported as a warning instead, which is a different // mechanism and must not double up. -TEST(TargetSideReport, NoGlossWhereTheSegmentNamesACLibrary) { +UTest no_gloss_where_the_segment_names_ac_library{"TargetSideReport/NoGlossWhereTheSegmentNamesACLibrary", [] { auto in = payload_linux(); in.compilerFamily = "llvm"; in.cAbi = provider("openkal-musl", "0.3.3", "musl"); @@ -627,13 +628,13 @@ TEST(TargetSideReport, NoGlossWhereTheSegmentNamesACLibrary) { in.envAxis = ts::EnvAxis::CLibrary; auto r = ts::format_report(ts::resolve(in), "x86_64-linux-gnu"); - EXPECT_EQ(r.find("not a C library"), std::string::npos) << r; - EXPECT_NE(ts::check_request(ts::resolve(in)), std::nullopt); -} + expect(eq(r.find("not a C library"), std::string::npos)) << r; + expect(ne(ts::check_request(ts::resolve(in)), std::nullopt)); +}}; // ── Rule two: declared requirements ────────────────────────────────────────── -TEST(TargetSideRequirements, ARequirementIsCheckedAgainstWhatResolved) { +UTest a_requirement_is_checked_against_what_resolved{"TargetSideRequirements/ARequirementIsCheckedAgainstWhatResolved", [] { auto in = payload_linux(); in.compilerFamily = "gcc"; in.cxxAbi = provider("acme-runtime", "0.1.0", "libc++"); @@ -642,9 +643,9 @@ TEST(TargetSideRequirements, ARequirementIsCheckedAgainstWhatResolved) { std::vector reqs{ {"acme-runtime@0.1.0", ts::CapLayer::Compiler, "llvm"}}; auto why = ts::check_requirements(r, reqs); - ASSERT_TRUE(why.has_value()); - EXPECT_NE(why->find("requires the compiler to be `llvm`"), std::string::npos); - EXPECT_NE(why->find("default = \"llvm\""), std::string::npos) + expect(fatal(why.has_value())); + expect(ne(why->find("requires the compiler to be `llvm`"), std::string::npos)); + expect(ne(why->find("default = \"llvm\""), std::string::npos)) << "a diagnostic that names no next step is a diagnostic the reader " "must still go and research"; // AND THE STEP IT NAMES MUST NOT BE A GLOBAL ONE. Until 2026.8.26.2 the @@ -653,22 +654,22 @@ TEST(TargetSideRequirements, ARequirementIsCheckedAgainstWhatResolved) { // asked. mcpp now applies the graph's requirement itself wherever its own // answer was revisable, so a global change is both unnecessary and, in the // only case that still reaches here, ineffective. - EXPECT_EQ(why->find("mcpp toolchain default"), std::string::npos) + expect(eq(why->find("mcpp toolchain default"), std::string::npos)) << "the remedy must stay inside the project that has the problem"; // With the caller naming where the compiler was stated, the advice points // at that statement instead of at the generic pair of spellings. auto stated = ts::check_requirements(r, reqs, "[toolchain] in mcpp.toml"); - ASSERT_TRUE(stated.has_value()); - EXPECT_NE(stated->find("[toolchain] in mcpp.toml"), std::string::npos); - EXPECT_NE(stated->find("remove it"), std::string::npos) + expect(fatal(stated.has_value())); + expect(ne(stated->find("[toolchain] in mcpp.toml"), std::string::npos)); + expect(ne(stated->find("remove it"), std::string::npos)) << "removing the statement is what lets mcpp take the graph's compiler"; in.compilerFamily = "llvm"; - EXPECT_EQ(ts::check_requirements(ts::resolve(in), reqs), std::nullopt); -} + expect(eq(ts::check_requirements(ts::resolve(in), reqs), std::nullopt)); +}}; -TEST(TargetSideRequirements, AnUnsuppliedLayerIsNamedAsSuch) { +UTest an_unsupplied_layer_is_named_as_such{"TargetSideRequirements/AnUnsuppliedLayerIsNamedAsSuch", [] { auto in = payload_linux(); in.compilerFamily = "llvm"; in.freestandingTarget = true; @@ -676,28 +677,28 @@ TEST(TargetSideRequirements, AnUnsuppliedLayerIsNamedAsSuch) { std::vector reqs{ {"acme-board@0.1.0", ts::CapLayer::CAbi, "picolibc"}}; auto why = ts::check_requirements(ts::resolve(in), reqs); - ASSERT_TRUE(why.has_value()); - EXPECT_NE(why->find("nothing supplies that layer"), std::string::npos); - EXPECT_NE(why->find("mcpp:c-abi=picolibc"), std::string::npos); -} + expect(fatal(why.has_value())); + expect(ne(why->find("nothing supplies that layer"), std::string::npos)); + expect(ne(why->find("mcpp:c-abi=picolibc"), std::string::npos)); +}}; // ── Rule one: one supplier per layer ───────────────────────────────────────── -TEST(TargetSideConflict, TwoSuppliersAreNamedTogetherWithHowEachArrived) { +UTest two_suppliers_are_named_together_with_how_each_arrived{"TargetSideConflict/TwoSuppliersAreNamedTogetherWithHowEachArrived", [] { ts::Conflict c; c.layer = ts::CapLayer::CAbi; c.first = "mcpplibs/openkal-musl@0.3.3"; c.firstVia = "a transitive dependency"; c.second = "acme/tinylibc@0.2.0"; auto text = ts::format_conflict(c); - EXPECT_NE(text.find("openkal-musl@0.3.3"), std::string::npos); - EXPECT_NE(text.find("tinylibc@0.2.0"), std::string::npos); - EXPECT_NE(text.find("a transitive dependency"), std::string::npos); - EXPECT_NE(text.find("a direct dependency"), std::string::npos); - EXPECT_NE(text.find("choice rather than a contribution"), std::string::npos) + expect(ne(text.find("openkal-musl@0.3.3"), std::string::npos)); + expect(ne(text.find("tinylibc@0.2.0"), std::string::npos)); + expect(ne(text.find("a transitive dependency"), std::string::npos)); + expect(ne(text.find("a direct dependency"), std::string::npos)); + expect(ne(text.find("choice rather than a contribution"), std::string::npos)) << "the reason matters: choosing wrong does not fail the link, it " "produces a program that runs and occasionally does not"; -} +}}; // ── Which layer decides the payload's C-library flags ─────────────────────── // @@ -733,38 +734,38 @@ mcpp::targetside::TargetSide side_with(mcpp::targetside::Origin cAbiOrigin, } } -TEST(TargetSideCLibrary, ThePayloadsCLibraryIsPrebuilt) { +UTest the_payloads_c_library_is_prebuilt{"TargetSideCLibrary/ThePayloadsCLibraryIsPrebuilt", [] { // The shape that failed: the graph supplies the kernel interface and the C // library is still the payload's, so the payload's flags must survive. - EXPECT_TRUE(side_with(mcpp::targetside::Origin::Payload).cAbi.prebuilt()); -} + expect(bool(side_with(mcpp::targetside::Origin::Payload).cAbi.prebuilt())); +}}; -TEST(TargetSideCLibrary, AnXpkgSysrootIsPrebuiltToo) { +UTest an_xpkg_sysroot_is_prebuilt_too{"TargetSideCLibrary/AnXpkgSysrootIsPrebuiltToo", [] { // The value a hand-written `fromGraph() || absent()` was silent about. A // sysroot from an xpkg is a directory that existed before resolution, like // a payload and unlike a package. - EXPECT_TRUE(side_with(mcpp::targetside::Origin::Xpkg).cAbi.prebuilt()); -} + expect(bool(side_with(mcpp::targetside::Origin::Xpkg).cAbi.prebuilt())); +}}; -TEST(TargetSideCLibrary, AGraphBuiltCLibraryIsNotPrebuilt) { - EXPECT_FALSE(side_with(mcpp::targetside::Origin::Graph).cAbi.prebuilt()); -} +UTest a_graph_built_c_library_is_not_prebuilt{"TargetSideCLibrary/AGraphBuiltCLibraryIsNotPrebuilt", [] { + expect(!(side_with(mcpp::targetside::Origin::Graph).cAbi.prebuilt())); +}}; -TEST(TargetSideCLibrary, NoCLibraryIsNotPrebuilt) { +UTest no_c_library_is_not_prebuilt{"TargetSideCLibrary/NoCLibraryIsNotPrebuilt", [] { // Nothing to reach, and the link line adds `-nostdlib -static` for it. - EXPECT_FALSE(side_with(mcpp::targetside::Origin::None).cAbi.prebuilt()); - EXPECT_TRUE(side_with(mcpp::targetside::Origin::None).cAbi.absent()); -} + expect(!(side_with(mcpp::targetside::Origin::None).cAbi.prebuilt())); + expect(bool(side_with(mcpp::targetside::Origin::None).cAbi.absent())); +}}; -TEST(TargetSideCLibrary, TheKernelInterfaceDoesNotDecideIt) { +UTest the_kernel_interface_does_not_decide_it{"TargetSideCLibrary/TheKernelInterfaceDoesNotDecideIt", [] { // The defect itself, as an assertion: every kernel-interface origin leaves // the answer to the C-ABI layer alone. using namespace mcpp::targetside; for (auto k : { Origin::Payload, Origin::Xpkg, Origin::Graph, Origin::None }) { - EXPECT_TRUE(side_with(Origin::Payload, k).cAbi.prebuilt()); - EXPECT_FALSE(side_with(Origin::Graph, k).cAbi.prebuilt()); + expect(bool(side_with(Origin::Payload, k).cAbi.prebuilt())); + expect(!(side_with(Origin::Graph, k).cAbi.prebuilt())); } -} +}}; // ── interfaces_not_provided — the whole of the engine's participation ─────── // @@ -774,23 +775,23 @@ TEST(TargetSideCLibrary, TheKernelInterfaceDoesNotDecideIt) { // member of either set; these tests use openkal's real names in one place and // an invented one in another, and the function behaves identically. -TEST(TargetSideInterfaces, EveryRequiredInterfaceProvidedYieldsNoGap) { +UTest every_required_interface_provided_yields_no_gap{"TargetSideInterfaces/EveryRequiredInterfaceProvidedYieldsNoGap", [] { std::vector required{"openkal.fs", "openkal.stream"}; std::vector provided{"openkal.abort", "openkal.stream", "openkal.memory", "openkal.fs"}; - EXPECT_TRUE(mcpp::targetside::interfaces_not_provided(required, provided) + expect(mcpp::targetside::interfaces_not_provided(required, provided) .empty()); -} +}}; -TEST(TargetSideInterfaces, AMissingInterfaceIsReportedByName) { +UTest a_missing_interface_is_reported_by_name{"TargetSideInterfaces/AMissingInterfaceIsReportedByName", [] { std::vector required{"openkal.fs", "openkal.net"}; std::vector provided{"openkal.fs"}; auto gap = mcpp::targetside::interfaces_not_provided(required, provided); - ASSERT_EQ(gap.size(), 1u); - EXPECT_EQ(gap[0], "openkal.net"); -} + expect(fatal(eq(gap.size(), 1u))); + expect(eq(gap[0], "openkal.net")); +}}; -TEST(TargetSideInterfaces, ANameThisEngineHasNeverHeardOfBehavesTheSame) { +UTest a_name_this_engine_has_never_heard_of_behaves_the_same{"TargetSideInterfaces/ANameThisEngineHasNeverHeardOfBehavesTheSame", [] { // The point of the opaque strings: a specification may add an interface // without a release of this engine, and a name that does not exist is // missing for the same reason a name that does exist and is not provided @@ -798,18 +799,17 @@ TEST(TargetSideInterfaces, ANameThisEngineHasNeverHeardOfBehavesTheSame) { std::vector required{"openkal.event", "some.other.spec/iface"}; std::vector provided{"openkal.fs"}; auto gap = mcpp::targetside::interfaces_not_provided(required, provided); - ASSERT_EQ(gap.size(), 2u); - EXPECT_EQ(gap[0], "openkal.event"); - EXPECT_EQ(gap[1], "some.other.spec/iface"); -} + expect(fatal(eq(gap.size(), 2u))); + expect(eq(gap[0], "openkal.event")); + expect(eq(gap[1], "some.other.spec/iface")); +}}; -TEST(TargetSideInterfaces, RequiringNothingIsNeverAGap) { - EXPECT_TRUE(mcpp::targetside::interfaces_not_provided({}, {}).empty()); - EXPECT_TRUE( - mcpp::targetside::interfaces_not_provided({}, {"openkal.fs"}).empty()); -} +UTest requiring_nothing_is_never_a_gap{"TargetSideInterfaces/RequiringNothingIsNeverAGap", [] { + expect(mcpp::targetside::interfaces_not_provided({}, {}).empty()); + expect(mcpp::targetside::interfaces_not_provided({}, {"openkal.fs"}).empty()); +}}; -TEST(TargetSideInterfaces, ProvidingNothingMakesEveryRequirementAGap) { +UTest providing_nothing_makes_every_requirement_a_gap{"TargetSideInterfaces/ProvidingNothingMakesEveryRequirementAGap", [] { // The shape a graph takes when the provider predates the key. The caller // (mcpp.build.prepare) does not reach this function in that case — a // provider that states nothing is not a provider that provides nothing — @@ -817,5 +817,5 @@ TEST(TargetSideInterfaces, ProvidingNothingMakesEveryRequirementAGap) { // the caller's and does not migrate here by accident. std::vector required{"openkal.fs"}; auto gap = mcpp::targetside::interfaces_not_provided(required, {}); - ASSERT_EQ(gap.size(), 1u); -} + expect(fatal(eq(gap.size(), 1u))); +}}; diff --git a/tests/unit/test_test_options.cpp b/tests/unit/test_test_options.cpp index 266a6a7d2..607aae7e5 100644 --- a/tests/unit/test_test_options.cpp +++ b/tests/unit/test_test_options.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.backend; import mcpp.build.execute; +using namespace mcpplibs::mtest; + using namespace mcpp::build; // `mcpp test` has to be BOUNDABLE, and its run half bounded by default. It was @@ -12,64 +14,64 @@ using namespace mcpp::build; // consume an entire unattended CI job with nothing to show for it. These // defaults are the contract; asserting them here means neither half can drift // back by an unnoticed edit. -TEST(TestOptions, RunIsBoundedByDefault) { +UTest run_is_bounded_by_default{"TestOptions/RunIsBoundedByDefault", [] { TestOptions to; - EXPECT_GT(to.timeoutSecs, 0) << "per-test run deadline must default to a limit"; - EXPECT_EQ(to.timeoutSecs, 300); -} + expect(gt(to.timeoutSecs, 0)) << "per-test run deadline must default to a limit"; + expect(eq(to.timeoutSecs, 300)); +}}; // The build deadline deliberately does NOT default on, and the asymmetry is // measured: a test binary running over five minutes is unusual, a cold // dependency build running over fifteen is ordinary (one mcpp-index member // builds OpenCV from source in 1019s on Linux, 1289s on Windows). A default // ceiling would turn slow-but-correct builds red and blame mcpp for it. -TEST(TestOptions, BuildDeadlineIsOptIn) { +UTest build_deadline_is_opt_in{"TestOptions/BuildDeadlineIsOptIn", [] { TestOptions to; - EXPECT_EQ(to.buildTimeoutSecs, 0) << "a default build ceiling would fail legitimate cold builds"; -} + expect(eq(to.buildTimeoutSecs, 0)) << "a default build ceiling would fail legitimate cold builds"; +}}; // 0 is still reachable — "no limit" did not disappear, it just has to be asked // for rather than being what you get by not thinking about it. -TEST(TestOptions, ZeroStillMeansUnlimited) { +UTest zero_still_means_unlimited{"TestOptions/ZeroStillMeansUnlimited", [] { TestOptions to; to.timeoutSecs = 0; to.buildTimeoutSecs = 0; - EXPECT_EQ(to.timeoutSecs, 0); - EXPECT_EQ(to.buildTimeoutSecs, 0); -} + expect(eq(to.timeoutSecs, 0)); + expect(eq(to.buildTimeoutSecs, 0)); +}}; // A build drive with no explicit ceiling must stay unbounded: BuildOptions is // shared with `mcpp build`, which is interactive and must not acquire a // surprise deadline just because `mcpp test` grew one. -TEST(BuildOptions, BuildTimeoutDefaultsToUnbounded) { +UTest build_timeout_defaults_to_unbounded{"BuildOptions/BuildTimeoutDefaultsToUnbounded", [] { BuildOptions bo; - EXPECT_EQ(bo.buildTimeoutSecs, 0u); -} + expect(eq(bo.buildTimeoutSecs, 0u)); +}}; -TEST(BuildOptions, CompileDatabaseIsOptionalByDefault) { +UTest compile_database_is_optional_by_default{"BuildOptions/CompileDatabaseIsOptionalByDefault", [] { BuildOptions options; - EXPECT_FALSE(options.requireCompileDatabase); -} + expect(!options.requireCompileDatabase); +}}; // The timeout verdict travels as a flag, not as a message prefix: run_tests // reports a timed-out compile differently from a broken one, and matching on // prose is exactly how that distinction rots silently. -TEST(BuildError, TimedOutIsAFlagAndDefaultsFalse) { +UTest timed_out_is_a_flag_and_defaults_false{"BuildError/TimedOutIsAFlagAndDefaultsFalse", [] { BuildError e{"build failed", std::nullopt, "", false}; - EXPECT_FALSE(e.timedOut); + expect(!e.timedOut); BuildError t{"build timed out after 900s", std::nullopt, "", true}; - EXPECT_TRUE(t.timedOut); -} + expect(bool(t.timedOut)); +}}; // The fan-out needs more than an exit code to report per-member progress: how // many tests ran, and where the time went. A zero-initialised summary must be // safe to print (the "no tests found" early return leaves it untouched). -TEST(TestRunSummary, DefaultsAreZeroAndPrintable) { +UTest defaults_are_zero_and_printable{"TestRunSummary/DefaultsAreZeroAndPrintable", [] { TestRunSummary s; - EXPECT_EQ(s.passed, 0); - EXPECT_EQ(s.failed, 0); - EXPECT_EQ(s.buildMs, 0); - EXPECT_EQ(s.runMs, 0); - EXPECT_EQ(s.elapsedMs, 0); - EXPECT_FALSE(s.packageError); -} + expect(eq(s.passed, 0)); + expect(eq(s.failed, 0)); + expect(eq(s.buildMs, 0)); + expect(eq(s.runMs, 0)); + expect(eq(s.elapsedMs, 0)); + expect(!s.packageError); +}}; diff --git a/tests/unit/test_test_protocol.cpp b/tests/unit/test_test_protocol.cpp index 68b9b7ac1..b78adf865 100644 --- a/tests/unit/test_test_protocol.cpp +++ b/tests/unit/test_test_protocol.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.test_protocol; +using namespace mcpplibs::mtest; + // The runner's side of the mtest test protocol, version 1 (docs/08 "Cases"): // the marker a program's file carries (P2.3), the report it writes (P5), a // crash's open cases (P5.4) and the patterns of `--case`. @@ -19,22 +21,22 @@ std::filesystem::path write_file(std::string_view name, std::string_view content return path; } -TEST(TestProtocol, TheMarkerStatesTheVersion) { - EXPECT_EQ(tp::marker_version(write_file("a", std::string("\x7f" "ELF....mtest-protocol=1\0rest", 30))), 1); - EXPECT_EQ(tp::marker_version(write_file("b", "no marker here")), 0); +UTest the_marker_states_the_version{"TestProtocol/TheMarkerStatesTheVersion", [] { + expect(eq(tp::marker_version(write_file("a", std::string("\x7f" "ELF....mtest-protocol=1\0rest", 30))), 1)); + expect(eq(tp::marker_version(write_file("b", "no marker here")), 0)); // The version is followed by a byte that is not a digit; a marker with no // version is none. - EXPECT_EQ(tp::marker_version(write_file("c", "mtest-protocol=x mtest-protocol=12;")), 12); - EXPECT_EQ(tp::marker_version("/nonexistent/program"), 0); -} + expect(eq(tp::marker_version(write_file("c", "mtest-protocol=x mtest-protocol=12;")), 12)); + expect(eq(tp::marker_version("/nonexistent/program"), 0)); +}}; -TEST(TestProtocol, TheMarkerAcrossABlockBoundary) { +UTest the_marker_across_a_block_boundary{"TestProtocol/TheMarkerAcrossABlockBoundary", [] { std::string bytes((1 << 20) - 5, 'a'); bytes += "mtest-protocol=1;"; - EXPECT_EQ(tp::marker_version(write_file("d", bytes)), 1); -} + expect(eq(tp::marker_version(write_file("d", bytes)), 1)); +}}; -TEST(TestProtocol, AReportGivesEachCaseAndItsFailures) { +UTest a_report_gives_each_case_and_its_failures{"TestProtocol/AReportGivesEachCaseAndItsFailures", [] { auto r = tp::read_report(write_file("r.ndjson", "{\"e\":\"program\",\"protocol\":1,\"level\":3,\"framework\":\"f\"}\n" "not json\n" @@ -46,31 +48,31 @@ TEST(TestProtocol, AReportGivesEachCaseAndItsFailures) { "{\"e\":\"future\",\"id\":\"b\"}\n" "{\"e\":\"missing\",\"id\":\"c\"}\n" "{\"e\":\"done\",\"status\":\"fail\",\"counts\":{}}\n"), 1); - EXPECT_EQ(r.level, 3); - EXPECT_EQ(r.framework, "f"); - EXPECT_TRUE(r.done); - ASSERT_EQ(r.cases.size(), 2u); - EXPECT_EQ(r.cases[0].status, "pass"); - EXPECT_DOUBLE_EQ(r.cases[0].ms, 1.5); - ASSERT_EQ(r.cases[1].failures.size(), 1u); - EXPECT_EQ(r.cases[1].failures[0].line, 9); - EXPECT_EQ(r.cases[1].failures[0].actual, "2"); - EXPECT_EQ(r.missing, std::vector{"c"}); - EXPECT_TRUE(r.failed()); - EXPECT_EQ(r.counts().at("fail"), 1); -} + expect(eq(r.level, 3)); + expect(eq(r.framework, "f")); + expect(bool(r.done)); + expect(fatal(eq(r.cases.size(), 2u))); + expect(eq(r.cases[0].status, "pass")); + expect(near(r.cases[0].ms, 1.5, 1e-9)); + expect(fatal(eq(r.cases[1].failures.size(), 1u))); + expect(eq(r.cases[1].failures[0].line, 9)); + expect(eq(r.cases[1].failures[0].actual, "2")); + expect(eq(r.missing, std::vector{"c"})); + expect(bool(r.failed())); + expect(eq(r.counts().at("fail"), 1)); +}}; -TEST(TestProtocol, ACaseACrashLeftOpenFailed) { +UTest a_case_a_crash_left_open_failed{"TestProtocol/ACaseACrashLeftOpenFailed", [] { auto r = tp::read_report(write_file("crash.ndjson", "{\"e\":\"program\",\"protocol\":1,\"level\":1}\n" "{\"e\":\"begin\",\"id\":\"x\",\"t\":0}\n"), 139); - EXPECT_FALSE(r.done); - ASSERT_EQ(r.cases.size(), 1u); - EXPECT_EQ(r.cases[0].status, "fail"); - EXPECT_NE(r.cases[0].reason.find("status 139"), std::string::npos); -} + expect(!r.done); + expect(fatal(eq(r.cases.size(), 1u))); + expect(eq(r.cases[0].status, "fail")); + expect(ne(r.cases[0].reason.find("status 139"), std::string::npos)); +}}; -TEST(TestProtocol, APatternMatchesIdentifiers) { +UTest a_pattern_matches_identifiers{"TestProtocol/APatternMatchesIdentifiers", [] { struct Row { std::string_view id, pattern; bool matches; }; constexpr Row rows[] = { {"fs/read", "fs/read", true}, @@ -84,29 +86,29 @@ TEST(TestProtocol, APatternMatchesIdentifiers) { {"a b/c", "a b/*", true}, }; for (const auto& row : rows) - EXPECT_EQ(tp::matches(row.id, row.pattern), row.matches) << row.id << " ~ " << row.pattern; -} + expect(eq(tp::matches(row.id, row.pattern), row.matches)) << row.id << " ~ " << row.pattern; +}}; -TEST(TestProtocol, ASelectionReachesTheCasesAListedCaseDeclares) { +UTest a_selection_reaches_the_cases_a_listed_case_declares{"TestProtocol/ASelectionReachesTheCasesAListedCaseDeclares", [] { // P3.4: `fields` is listed; the cases its body declares are reported when it runs. const std::vector listed = {{"fields"}, {"empty"}}; using V = std::vector; - EXPECT_EQ(tp::selection(listed, {"fields"}), V{"fields"}); - EXPECT_EQ(tp::selection(listed, {"fields/one"}), V{"fields/one"}); // passed as it is - EXPECT_EQ(tp::selection(listed, {"fields/o*"}), V{"fields"}); // the listed case, all of it - EXPECT_EQ(tp::selection(listed, {"e*", "fields/one", "fields"}), (V{"empty", "fields/one", "fields"})); - EXPECT_EQ(tp::selection(listed, {"other/one"}), V{}); -} + expect(eq(tp::selection(listed, {"fields"}), V{"fields"})); + expect(eq(tp::selection(listed, {"fields/one"}), V{"fields/one"})); // passed as it is + expect(eq(tp::selection(listed, {"fields/o*"}), V{"fields"})); // the listed case, all of it + expect(eq(tp::selection(listed, {"e*", "fields/one", "fields"}), (V{"empty", "fields/one", "fields"}))); + expect(eq(tp::selection(listed, {"other/one"}), V{})); +}}; -TEST(TestProtocol, TheVariablesOfARun) { +UTest the_variables_of_a_run{"TestProtocol/TheVariablesOfARun", [] { auto env = tp::variables("run", "/r", std::filesystem::path("/s"), "/t", 7); std::map m(env.begin(), env.end()); - EXPECT_EQ(m.at("MTEST_PROTOCOL"), "1"); - EXPECT_EQ(m.at("MTEST_MODE"), "run"); - EXPECT_EQ(m.at("MTEST_SELECT"), std::filesystem::path("/s").string()); - EXPECT_EQ(m.at("MTEST_SEED"), "7"); + expect(eq(m.at("MTEST_PROTOCOL"), "1")); + expect(eq(m.at("MTEST_MODE"), "run")); + expect(eq(m.at("MTEST_SELECT"), std::filesystem::path("/s").string())); + expect(eq(m.at("MTEST_SEED"), "7")); auto list = tp::variables("list", "/r", std::nullopt, "/t", std::nullopt); - EXPECT_TRUE(std::ranges::none_of(list, [](auto& kv) { return kv.first == "MTEST_SELECT" || kv.first == "MTEST_SEED"; })); -} + expect(std::ranges::none_of(list, [](auto& kv) { return kv.first == "MTEST_SELECT" || kv.first == "MTEST_SEED"; })); +}}; } // namespace diff --git a/tests/unit/test_test_targets.cpp b/tests/unit/test_test_targets.cpp index d03eff962..54c4f8a03 100644 --- a/tests/unit/test_test_targets.cpp +++ b/tests/unit/test_test_targets.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.test_targets; +using namespace mcpplibs::mtest; + namespace { struct Tmp { @@ -30,7 +32,7 @@ void write_manifest(const std::filesystem::path& root, std::string_view body) { write_file(root / "mcpp.toml", body); } -TEST(TestTargets, NestedNamesUseTestsRelativePath) { +UTest nested_names_use_tests_relative_path{"TestTargets/NestedNamesUseTestsRelativePath", [] { Tmp tmp; write_manifest(tmp.path, R"([package] name = "demo" @@ -39,14 +41,13 @@ version = "0.1.0" write_file(tmp.path / "tests/tagged/nested/smoke.cpp", "int main() {}\n"); auto result = mcpp::build::discover_test_targets(tmp.path, {}); - ASSERT_TRUE(result.has_value()) << result.error(); - ASSERT_EQ(result->targets.size(), 1u); - EXPECT_EQ(result->targets[0].name, "tagged/nested/smoke"); - EXPECT_EQ(std::filesystem::path(result->targets[0].main), - std::filesystem::path("tests") / "tagged" / "nested" / "smoke.cpp"); -} + if (auto&& failed = expect(fatal(result.has_value())); !failed) failed << result.error(); + expect(fatal(eq(result->targets.size(), 1u))); + expect(eq(result->targets[0].name, "tagged/nested/smoke")); + expect(eq(std::filesystem::path(result->targets[0].main), std::filesystem::path("tests") / "tagged" / "nested" / "smoke.cpp")); +}}; -TEST(TestTargets, GlobFlagsBecomePerTargetFlags) { +UTest glob_flags_become_per_target_flags{"TestTargets/GlobFlagsBecomePerTargetFlags", [] { Tmp tmp; write_manifest(tmp.path, R"([package] name = "demo" @@ -58,14 +59,14 @@ flags = [{ glob = "tests/**/*.cpp", defines = ["TEST_FEATURE"], cflags = ["-Wall write_file(tmp.path / "tests/main.cpp", "int main() {}\n"); auto result = mcpp::build::discover_test_targets(tmp.path, {}); - ASSERT_TRUE(result.has_value()) << result.error(); - ASSERT_EQ(result->targets.size(), 1u); - EXPECT_EQ(result->targets[0].defines, std::vector{"TEST_FEATURE"}); - EXPECT_EQ(result->targets[0].cflags, std::vector{"-Wall"}); - EXPECT_EQ(result->targets[0].cxxflags, std::vector{"-Wextra"}); -} - -TEST(TestTargets, PackageFilterScopesWorkspaceMember) { + if (auto&& failed = expect(fatal(result.has_value())); !failed) failed << result.error(); + expect(fatal(eq(result->targets.size(), 1u))); + expect(eq(result->targets[0].defines, std::vector{"TEST_FEATURE"})); + expect(eq(result->targets[0].cflags, std::vector{"-Wall"})); + expect(eq(result->targets[0].cxxflags, std::vector{"-Wextra"})); +}}; + +UTest package_filter_scopes_workspace_member{"TestTargets/PackageFilterScopesWorkspaceMember", [] { Tmp tmp; write_manifest(tmp.path, R"([workspace] members = ["a", "b"] @@ -82,14 +83,13 @@ version = "0.1.0" write_file(tmp.path / "b/tests/main.cpp", "int main() {}\n"); auto result = mcpp::build::discover_test_targets(tmp.path, "a"); - ASSERT_TRUE(result.has_value()) << result.error(); - ASSERT_EQ(result->packageRoot, tmp.path / "a"); - ASSERT_EQ(result->targets.size(), 1u); - EXPECT_EQ(std::filesystem::path(result->targets[0].main), - std::filesystem::path("tests") / "main.cpp"); -} + if (auto&& failed = expect(fatal(result.has_value())); !failed) failed << result.error(); + expect(fatal(eq(result->packageRoot, tmp.path / "a"))); + expect(fatal(eq(result->targets.size(), 1u))); + expect(eq(std::filesystem::path(result->targets[0].main), std::filesystem::path("tests") / "main.cpp")); +}}; -TEST(TestTargets, DirectorySymlinkKeepsLexicalPackageBoundary) { +UTest directory_symlink_keeps_lexical_package_boundary{"TestTargets/DirectorySymlinkKeepsLexicalPackageBoundary", [] { Tmp tmp; write_manifest(tmp.path, R"([package] name = "demo" @@ -102,19 +102,18 @@ version = "0.1.0" std::error_code ec; std::filesystem::create_directory_symlink( outside.path, tmp.path / "tests/vendor", ec); - if (ec) GTEST_SKIP() << "directory symlink unavailable: " << ec.message(); + if (ec) skip([&] { std::ostringstream text; text << "directory symlink unavailable: " << ec.message(); return text.str(); }()); auto result = mcpp::build::discover_test_targets(tmp.path, {}); - ASSERT_TRUE(result.has_value()) << result.error(); - ASSERT_EQ(result->targets.size(), 1u); - EXPECT_EQ(result->targets[0].name, "vendor/external"); - EXPECT_EQ(std::filesystem::path(result->targets[0].main), - std::filesystem::path("tests") / "vendor" / "external.cpp"); -} + if (auto&& failed = expect(fatal(result.has_value())); !failed) failed << result.error(); + expect(fatal(eq(result->targets.size(), 1u))); + expect(eq(result->targets[0].name, "vendor/external")); + expect(eq(std::filesystem::path(result->targets[0].main), std::filesystem::path("tests") / "vendor" / "external.cpp")); +}}; // `[test] discover` (#634 A5): names are relative to the fixed directory of // the glob that found the file, `main` stays relative to the package root. -TEST(TestTargets, DiscoverNamesAreRelativeToTheGlobsDirectory) { +UTest discover_names_are_relative_to_the_globs_directory{"TestTargets/DiscoverNamesAreRelativeToTheGlobsDirectory", [] { Tmp tmp; write_manifest(tmp.path, R"([package] name = "demo" @@ -127,17 +126,16 @@ discover = ["checks/**/*.cpp"] write_file(tmp.path / "tests/ignored.cpp", "int main() {}\n"); auto result = mcpp::build::discover_test_targets(tmp.path, {}); - ASSERT_TRUE(result.has_value()) << result.error(); - EXPECT_TRUE(result->discoverDeclared); - ASSERT_EQ(result->targets.size(), 1u); - EXPECT_EQ(result->targets[0].name, "deep/a"); - EXPECT_EQ(std::filesystem::path(result->targets[0].main), - std::filesystem::path("checks") / "deep" / "a.cpp"); -} + if (auto&& failed = expect(fatal(result.has_value())); !failed) failed << result.error(); + expect(bool(result->discoverDeclared)); + expect(fatal(eq(result->targets.size(), 1u))); + expect(eq(result->targets[0].name, "deep/a")); + expect(eq(std::filesystem::path(result->targets[0].main), std::filesystem::path("checks") / "deep" / "a.cpp")); +}}; // An exclusion removes a file whichever positive glob found it, and a glob // with no fixed directory names files relative to the package root. -TEST(TestTargets, DiscoverExclusionsApplyAcrossGlobs) { +UTest discover_exclusions_apply_across_globs{"TestTargets/DiscoverExclusionsApplyAcrossGlobs", [] { Tmp tmp; write_manifest(tmp.path, R"([package] name = "demo" @@ -151,13 +149,13 @@ discover = ["tests/**/*.cpp", "*_check.cpp", "!tests/fixtures/**"] write_file(tmp.path / "root_check.cpp", "int main() {}\n"); auto result = mcpp::build::discover_test_targets(tmp.path, {}); - ASSERT_TRUE(result.has_value()) << result.error(); + if (auto&& failed = expect(fatal(result.has_value())); !failed) failed << result.error(); std::set names; for (auto const& t : result->targets) names.insert(t.name); - EXPECT_EQ(names, (std::set{"kept", "root_check"})); -} + expect(eq(names, (std::set{"kept", "root_check"}))); +}}; -TEST(TestTargets, AnEmptyDiscoverFindsNothing) { +UTest an_empty_discover_finds_nothing{"TestTargets/AnEmptyDiscoverFindsNothing", [] { Tmp tmp; write_manifest(tmp.path, R"([package] name = "demo" @@ -169,13 +167,13 @@ discover = [] write_file(tmp.path / "tests/main.cpp", "int main() {}\n"); auto result = mcpp::build::discover_test_targets(tmp.path, {}); - ASSERT_TRUE(result.has_value()) << result.error(); - EXPECT_TRUE(result->discoverDeclared); - EXPECT_TRUE(result->discover.empty()); - EXPECT_TRUE(result->targets.empty()); -} + if (auto&& failed = expect(fatal(result.has_value())); !failed) failed << result.error(); + expect(bool(result->discoverDeclared)); + expect(result->discover.empty()); + expect(result->targets.empty()); +}}; -TEST(TestTargets, TwoFilesWithOneNameAreRefusedNamingBoth) { +UTest two_files_with_one_name_are_refused_naming_both{"TestTargets/TwoFilesWithOneNameAreRefusedNamingBoth", [] { Tmp tmp; write_manifest(tmp.path, R"([package] name = "demo" @@ -188,14 +186,14 @@ discover = ["tests/*.cpp", "checks/*.cpp"] write_file(tmp.path / "checks/same.cpp", "int main() {}\n"); auto result = mcpp::build::discover_test_targets(tmp.path, {}); - ASSERT_FALSE(result.has_value()); - EXPECT_NE(result.error().find("duplicate test name 'same'"), std::string::npos) << result.error(); - EXPECT_NE(result.error().find("tests/same.cpp"), std::string::npos) << result.error(); - EXPECT_NE(result.error().find("checks/same.cpp"), std::string::npos) << result.error(); -} + expect(fatal(!result.has_value())); + if (auto&& failed = expect(ne(result.error().find("duplicate test name 'same'"), std::string::npos)); !failed) failed << result.error(); + if (auto&& failed = expect(ne(result.error().find("tests/same.cpp"), std::string::npos)); !failed) failed << result.error(); + if (auto&& failed = expect(ne(result.error().find("checks/same.cpp"), std::string::npos)); !failed) failed << result.error(); +}}; // Without the key the set, and every name, is the one earlier releases gave. -TEST(TestTargets, WithoutDiscoverTheDefaultIsTestsGlob) { +UTest without_discover_the_default_is_tests_glob{"TestTargets/WithoutDiscoverTheDefaultIsTestsGlob", [] { Tmp tmp; write_manifest(tmp.path, R"([package] name = "demo" @@ -205,23 +203,23 @@ version = "0.1.0" write_file(tmp.path / "checks/b.cpp", "int main() {}\n"); auto result = mcpp::build::discover_test_targets(tmp.path, {}); - ASSERT_TRUE(result.has_value()) << result.error(); - EXPECT_FALSE(result->discoverDeclared); - EXPECT_EQ(result->discover, std::vector{"tests/**/*.cpp"}); - ASSERT_EQ(result->targets.size(), 1u); - EXPECT_EQ(result->targets[0].name, "unit/a"); -} - -TEST(TestTargets, BrokenManifestStillReturnsInventory) { + if (auto&& failed = expect(fatal(result.has_value())); !failed) failed << result.error(); + expect(!result->discoverDeclared); + expect(eq(result->discover, std::vector{"tests/**/*.cpp"})); + expect(fatal(eq(result->targets.size(), 1u))); + expect(eq(result->targets[0].name, "unit/a")); +}}; + +UTest broken_manifest_still_returns_inventory{"TestTargets/BrokenManifestStillReturnsInventory", [] { Tmp tmp; write_file(tmp.path / "mcpp.toml", "this is not valid TOML\n"); write_file(tmp.path / "tests/main.cpp", "int main() {}\n"); auto result = mcpp::build::discover_test_targets(tmp.path, {}); - ASSERT_TRUE(result.has_value()) << result.error(); - ASSERT_EQ(result->packageRoot, tmp.path); - ASSERT_EQ(result->targets.size(), 1u); - EXPECT_EQ(result->targets[0].name, "main"); -} + if (auto&& failed = expect(fatal(result.has_value())); !failed) failed << result.error(); + expect(fatal(eq(result->packageRoot, tmp.path))); + expect(fatal(eq(result->targets.size(), 1u))); + expect(eq(result->targets[0].name, "main")); +}}; } // namespace diff --git a/tests/unit/test_toml.cpp b/tests/unit/test_toml.cpp index e6d5ae13b..5ab1ce0c9 100644 --- a/tests/unit/test_toml.cpp +++ b/tests/unit/test_toml.cpp @@ -1,165 +1,166 @@ -#include import std; +import mcpplibs.mtest; import mcpp.libs.toml; +using namespace mcpplibs::mtest; + namespace t = mcpp::libs::toml; -TEST(Toml, EmptyDocumentParses) { +UTest empty_document_parses{"Toml/EmptyDocumentParses", [] { auto d = t::parse(""); - ASSERT_TRUE(d.has_value()); - EXPECT_TRUE(d->root().empty()); -} + expect(fatal(d.has_value())); + expect(d->root().empty()); +}}; -TEST(Toml, SimpleKeyValue) { +UTest simple_key_value{"Toml/SimpleKeyValue", [] { auto d = t::parse("key = \"value\"\nnum = 42\nflag = true"); - ASSERT_TRUE(d.has_value()) << d.error().message; - EXPECT_EQ(d->get_string("key").value_or(""), "value"); - EXPECT_EQ(d->get_int("num").value_or(-1), 42); - EXPECT_EQ(d->get_bool("flag").value_or(false), true); -} + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; + expect(eq(d->get_string("key").value_or(""), "value")); + expect(eq(d->get_int("num").value_or(-1), 42)); + expect(eq(d->get_bool("flag").value_or(false), true)); +}}; -TEST(Toml, NestedTables) { +UTest nested_tables{"Toml/NestedTables", [] { auto d = t::parse(R"( [a.b] x = "deep" [a] y = "shallow" )"); - ASSERT_TRUE(d.has_value()) << d.error().message; - EXPECT_EQ(d->get_string("a.b.x").value_or(""), "deep"); - EXPECT_EQ(d->get_string("a.y").value_or(""), "shallow"); -} + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; + expect(eq(d->get_string("a.b.x").value_or(""), "deep")); + expect(eq(d->get_string("a.y").value_or(""), "shallow")); +}}; -TEST(Toml, ArrayOfStrings) { +UTest array_of_strings{"Toml/ArrayOfStrings", [] { auto d = t::parse(R"(items = ["a", "b", "c"])"); - ASSERT_TRUE(d.has_value()); + expect(fatal(d.has_value())); auto arr = d->get_string_array("items"); - ASSERT_TRUE(arr.has_value()); - EXPECT_EQ(arr->size(), 3u); - EXPECT_EQ((*arr)[0], "a"); - EXPECT_EQ((*arr)[2], "c"); -} + expect(fatal(arr.has_value())); + expect(eq(arr->size(), 3u)); + expect(eq((*arr)[0], "a")); + expect(eq((*arr)[2], "c")); +}}; -TEST(Toml, ArrayAllowsTrailingComma) { +UTest array_allows_trailing_comma{"Toml/ArrayAllowsTrailingComma", [] { auto d = t::parse(R"( items = [ "a", "b", ] )"); - ASSERT_TRUE(d.has_value()) << d.error().message; + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; auto arr = d->get_string_array("items"); - ASSERT_TRUE(arr.has_value()); - EXPECT_EQ(*arr, std::vector({"a", "b"})); -} + expect(fatal(arr.has_value())); + expect(eq(*arr, std::vector({"a", "b"}))); +}}; -TEST(Toml, EscapedString) { +UTest escaped_string{"Toml/EscapedString", [] { auto d = t::parse(R"(s = "a\tb\nc")"); - ASSERT_TRUE(d.has_value()); - EXPECT_EQ(d->get_string("s").value_or(""), std::string("a\tb\nc")); -} + expect(fatal(d.has_value())); + expect(eq(d->get_string("s").value_or(""), std::string("a\tb\nc"))); +}}; // TOML 1.0's escapes: \b, \f and the Unicode forms were refused as "invalid // escape", so a valid manifest written with them could not be read. -TEST(Toml, EveryTomlEscapeIsRead) { +UTest every_toml_escape_is_read{"Toml/EveryTomlEscapeIsRead", [] { auto d = t::parse(R"(s = "a\bb\fc")"); - ASSERT_TRUE(d.has_value()) << d.error().message; - EXPECT_EQ(d->get_string("s").value_or(""), std::string("a\bb\fc")); + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; + expect(eq(d->get_string("s").value_or(""), std::string("a\bb\fc"))); // One, two, three and four UTF-8 bytes, upper- and lower-case hex. d = t::parse(R"(s = "\u0041 caf\u00e9 \u4E2D \U0001F600")"); - ASSERT_TRUE(d.has_value()) << d.error().message; - EXPECT_EQ(d->get_string("s").value_or(""), - std::string("A caf\xC3\xA9 \xE4\xB8\xAD \xF0\x9F\x98\x80")); + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; + expect(eq(d->get_string("s").value_or(""), std::string("A caf\xC3\xA9 \xE4\xB8\xAD \xF0\x9F\x98\x80"))); d = t::parse("s = \"\"\"\nJos\\u00e9\\U0000004A\"\"\""); - ASSERT_TRUE(d.has_value()) << d.error().message; - EXPECT_EQ(d->get_string("s").value_or(""), std::string("Jos\xC3\xA9J")); + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; + expect(eq(d->get_string("s").value_or(""), std::string("Jos\xC3\xA9J"))); // A quoted key is a basic string too. d = t::parse(R"("caf\u00e9" = 1)"); - ASSERT_TRUE(d.has_value()) << d.error().message; - EXPECT_EQ(d->root().count("caf\xC3\xA9"), 1u); + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; + expect(eq(d->root().count("caf\xC3\xA9"), 1u)); // A literal string keeps its backslashes. d = t::parse(R"(s = 'C:\u0041')"); - ASSERT_TRUE(d.has_value()) << d.error().message; - EXPECT_EQ(d->get_string("s").value_or(""), R"(C:\u0041)"); -} - -TEST(Toml, AUnicodeEscapeNamesAScalarValue) { - EXPECT_FALSE(t::parse(R"(s = "\u12")").has_value()); // too few digits - EXPECT_FALSE(t::parse(R"(s = "\u12G4")").has_value()); // not hex - EXPECT_FALSE(t::parse(R"(s = "\U0001F60")").has_value()); // \U takes eight - EXPECT_FALSE(t::parse(R"(s = "\uD800")").has_value()); // a surrogate - EXPECT_FALSE(t::parse(R"(s = "\U00110000")").has_value()); // past U+10FFFF - EXPECT_FALSE(t::parse(R"(s = "\x41")").has_value()); // not TOML 1.0 + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; + expect(eq(d->get_string("s").value_or(""), R"(C:\u0041)")); +}}; + +UTest a_unicode_escape_names_a_scalar_value{"Toml/AUnicodeEscapeNamesAScalarValue", [] { + expect(!(t::parse(R"(s = "\u12")").has_value())); // too few digits + expect(!(t::parse(R"(s = "\u12G4")").has_value())); // not hex + expect(!(t::parse(R"(s = "\U0001F60")").has_value())); // \U takes eight + expect(!(t::parse(R"(s = "\uD800")").has_value())); // a surrogate + expect(!(t::parse(R"(s = "\U00110000")").has_value())); // past U+10FFFF + expect(!(t::parse(R"(s = "\x41")").has_value())); // not TOML 1.0 auto bad = t::parse("s = \"ok\\uDFFF\""); - ASSERT_FALSE(bad.has_value()); - EXPECT_NE(bad.error().message.find("\\uDFFF"), std::string::npos) << bad.error().message; -} + expect(fatal(!bad.has_value())); + if (auto&& failed = expect(ne(bad.error().message.find("\\uDFFF"), std::string::npos)); !failed) failed << bad.error().message; +}}; -TEST(Toml, RejectUnterminatedString) { +UTest reject_unterminated_string{"Toml/RejectUnterminatedString", [] { auto d = t::parse(R"(x = "no end)"); - EXPECT_FALSE(d.has_value()); -} + expect(!d.has_value()); +}}; -TEST(Toml, CommentsIgnored) { +UTest comments_ignored{"Toml/CommentsIgnored", [] { auto d = t::parse(R"( # top comment x = 1 # trailing isn't supported but full-line is y = 2 )"); - ASSERT_TRUE(d.has_value()) << d.error().message; - EXPECT_EQ(d->get_int("x").value_or(-1), 1); - EXPECT_EQ(d->get_int("y").value_or(-1), 2); -} - -TEST(Toml, EscapeStringHelper) { - EXPECT_EQ(t::escape_string("hello"), "\"hello\""); - EXPECT_EQ(t::escape_string("a\"b"), "\"a\\\"b\""); - EXPECT_EQ(t::escape_string("a\\b"), "\"a\\\\b\""); -} - -TEST(Toml, MultilineBasicString) { + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; + expect(eq(d->get_int("x").value_or(-1), 1)); + expect(eq(d->get_int("y").value_or(-1), 2)); +}}; + +UTest escape_string_helper{"Toml/EscapeStringHelper", [] { + expect(eq(t::escape_string("hello"), "\"hello\"")); + expect(eq(t::escape_string("a\"b"), "\"a\\\"b\"")); + expect(eq(t::escape_string("a\\b"), "\"a\\\\b\"")); +}}; + +UTest multiline_basic_string{"Toml/MultilineBasicString", [] { auto d = t::parse("body = \"\"\"\nline1\nline2 \"quoted\"\n\"\"\"\nafter = 1"); - ASSERT_TRUE(d.has_value()) << d.error().message; + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; // First newline after the opening delimiter is trimmed (TOML 1.0). - EXPECT_EQ(d->get_string("body").value_or(""), "line1\nline2 \"quoted\"\n"); - EXPECT_EQ(d->get_int("after").value_or(-1), 1); -} + expect(eq(d->get_string("body").value_or(""), "line1\nline2 \"quoted\"\n")); + expect(eq(d->get_int("after").value_or(-1), 1)); +}}; -TEST(Toml, MultilineLiteralString) { +UTest multiline_literal_string{"Toml/MultilineLiteralString", [] { auto d = t::parse("raw = '''\nno \\escapes here\n'''"); - ASSERT_TRUE(d.has_value()) << d.error().message; - EXPECT_EQ(d->get_string("raw").value_or(""), "no \\escapes here\n"); -} + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; + expect(eq(d->get_string("raw").value_or(""), "no \\escapes here\n")); +}}; -TEST(Toml, MultilineLineEndingBackslash) { +UTest multiline_line_ending_backslash{"Toml/MultilineLineEndingBackslash", [] { auto d = t::parse("s = \"\"\"a\\\n b\"\"\""); - ASSERT_TRUE(d.has_value()) << d.error().message; - EXPECT_EQ(d->get_string("s").value_or(""), "ab"); -} + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; + expect(eq(d->get_string("s").value_or(""), "ab")); +}}; -TEST(Toml, MultilineUnterminatedIsError) { +UTest multiline_unterminated_is_error{"Toml/MultilineUnterminatedIsError", [] { auto d = t::parse("s = \"\"\"never closed"); - EXPECT_FALSE(d.has_value()); -} + expect(!d.has_value()); +}}; -TEST(Toml, EmptyStringStillParses) { +UTest empty_string_still_parses{"Toml/EmptyStringStillParses", [] { auto d = t::parse("e = \"\"\nx = 2"); - ASSERT_TRUE(d.has_value()) << d.error().message; - EXPECT_EQ(d->get_string("e").value_or("?"), ""); - EXPECT_EQ(d->get_int("x").value_or(-1), 2); -} + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; + expect(eq(d->get_string("e").value_or("?"), "")); + expect(eq(d->get_int("x").value_or(-1), 2)); +}}; // #227: `[[dotted.path]]` array-of-tables — each occurrence appends a fresh // table to an array at that dotted path; keys after it fill THAT table until // the next `[...]`/`[[...]]` header. Declaration order must be preserved // (it's the "later entries win" override order for [build].flags). -TEST(Toml, ParsesArrayOfTables) { +UTest parses_array_of_tables{"Toml/ParsesArrayOfTables", [] { auto d = t::parse(R"( [[build.flags]] glob = "a/**" @@ -167,27 +168,27 @@ cxxflags = ["-DX=1"] [[build.flags]] glob = "b/**" )"); - ASSERT_TRUE(d.has_value()) << d.error().message; + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; auto* v = d->get("build.flags"); - ASSERT_NE(v, nullptr); - ASSERT_TRUE(v->is_array()); + expect(fatal(ne(v, nullptr))); + expect(fatal(v->is_array())); auto& arr = v->as_array(); - ASSERT_EQ(arr.size(), 2u); - ASSERT_TRUE(arr[0].is_table()); - EXPECT_EQ(arr[0].as_table().at("glob").as_string(), "a/**"); + expect(fatal(eq(arr.size(), 2u))); + expect(fatal(arr[0].is_table())); + expect(eq(arr[0].as_table().at("glob").as_string(), "a/**")); auto cx = arr[0].as_table().at("cxxflags"); - ASSERT_TRUE(cx.is_array()); - ASSERT_EQ(cx.as_array().size(), 1u); - EXPECT_EQ(cx.as_array()[0].as_string(), "-DX=1"); - ASSERT_TRUE(arr[1].is_table()); - EXPECT_EQ(arr[1].as_table().at("glob").as_string(), "b/**"); - EXPECT_EQ(arr[1].as_table().count("cxxflags"), 0u); -} + expect(fatal(cx.is_array())); + expect(fatal(eq(cx.as_array().size(), 1u))); + expect(eq(cx.as_array()[0].as_string(), "-DX=1")); + expect(fatal(arr[1].is_table())); + expect(eq(arr[1].as_table().at("glob").as_string(), "b/**")); + expect(eq(arr[1].as_table().count("cxxflags"), 0u)); +}}; // Regular `[table]` and inline-array-of-tables parsing must be unaffected by // AOT support — array-of-tables is an ADDITIONAL grammar form, not a // replacement. -TEST(Toml, RegularTablesStillWorkAlongsideArrayOfTables) { +UTest regular_tables_still_work_alongside_array_of_tables{"Toml/RegularTablesStillWorkAlongsideArrayOfTables", [] { auto d = t::parse(R"( [package] name = "x" @@ -196,11 +197,11 @@ glob = "a/**" [dependencies] foo = "1.0" )"); - ASSERT_TRUE(d.has_value()) << d.error().message; - EXPECT_EQ(d->get_string("package.name").value_or(""), "x"); - EXPECT_EQ(d->get_string("dependencies.foo").value_or(""), "1.0"); + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error().message; + expect(eq(d->get_string("package.name").value_or(""), "x")); + expect(eq(d->get_string("dependencies.foo").value_or(""), "1.0")); auto* v = d->get("build.flags"); - ASSERT_NE(v, nullptr); - ASSERT_TRUE(v->is_array()); - EXPECT_EQ(v->as_array().size(), 1u); -} + expect(fatal(ne(v, nullptr))); + expect(fatal(v->is_array())); + expect(eq(v->as_array().size(), 1u)); +}}; diff --git a/tests/unit/test_toml_serialize.cpp b/tests/unit/test_toml_serialize.cpp index ca9217cf6..8e42aedc4 100644 --- a/tests/unit/test_toml_serialize.cpp +++ b/tests/unit/test_toml_serialize.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.libs.toml; import mcpp.manifest; +using namespace mcpplibs::mtest; + // `serialize` is what `mcpp publish` writes a normalised manifest with (#690). // The property it owes is a round trip: the text it produces parses into the // same tree AND the same set of explicit tables, because the manifest reader @@ -44,21 +46,21 @@ bool same(const t::Value& a, const t::Value& b) { void expect_round_trip(std::string_view source, std::string_view label) { auto first = t::parse(source); - ASSERT_TRUE(first.has_value()) << label << ": " << first.error().message; + if (auto&& failed = expect(fatal(first.has_value())); !failed) failed << label << ": " << first.error().message; auto text = t::serialize(*first); auto second = t::parse(text); - ASSERT_TRUE(second.has_value()) << label << ": the serialised text does not parse: " + if (auto&& failed = expect(fatal(second.has_value())); !failed) failed << label << ": the serialised text does not parse: " << second.error().message << "\n" << text; - EXPECT_TRUE(same(t::Value{first->root()}, t::Value{second->root()})) + expect(bool(same(t::Value{first->root()}, t::Value{second->root()}))) << label << ": the tree changed\n" << text; - EXPECT_EQ(first->explicit_tables(), second->explicit_tables()) + expect(eq(first->explicit_tables(), second->explicit_tables())) << label << ": the explicit tables changed\n" << text; // The manifest reader sees the same dependencies. A namespace table that // came back as a dotted selector, or the reverse, changes the keys here. auto m1 = mcpp::manifest::parse_string(source); auto m2 = mcpp::manifest::parse_string(text); - ASSERT_EQ(m1.has_value(), m2.has_value()) << label; + expect(fatal(eq(m1.has_value(), m2.has_value()))) << label; if (!m1) return; auto keys = [](auto const& map) { std::vector out; @@ -66,16 +68,16 @@ void expect_round_trip(std::string_view source, std::string_view label) { out.push_back(std::format("{}|{}|{}|{}", k, v.version, v.path, v.namespace_)); return out; }; - EXPECT_EQ(keys(m1->dependencies), keys(m2->dependencies)) << label << "\n" << text; - EXPECT_EQ(keys(m1->devDependencies), keys(m2->devDependencies)) << label; - EXPECT_EQ(keys(m1->buildDependencies), keys(m2->buildDependencies)) << label; - EXPECT_EQ(m1->package.name, m2->package.name) << label; - EXPECT_EQ(m1->package.version, m2->package.version) << label; + expect(eq(keys(m1->dependencies), keys(m2->dependencies))) << label << "\n" << text; + expect(eq(keys(m1->devDependencies), keys(m2->devDependencies))) << label; + expect(eq(keys(m1->buildDependencies), keys(m2->buildDependencies))) << label; + expect(eq(m1->package.name, m2->package.name)) << label; + expect(eq(m1->package.version, m2->package.version)) << label; } } // namespace -TEST(TomlSerialize, HeadersInlineTablesAndDottedKeys) { +UTest headers_inline_tables_and_dotted_keys{"TomlSerialize/HeadersInlineTablesAndDottedKeys", [] { expect_round_trip(R"( [package] name = "demo" @@ -108,9 +110,9 @@ extra = { version = "2.0", features = ["a"] } kind = "bin" main = "src/main.cpp" )", "inline fixture"); -} +}}; -TEST(TomlSerialize, ImplicitContainerWithExplicitDescendant) { +UTest implicit_container_with_explicit_descendant{"TomlSerialize/ImplicitContainerWithExplicitDescendant", [] { // `dependencies` is created by a dotted key at the root and also has an // explicit descendant: its own entries must stay dotted keys. expect_round_trip(R"( @@ -123,13 +125,13 @@ bar = "2.0" name = "p" version = "0.1.0" )", "implicit container"); -} +}}; // The reader accepts every TOML 1.0 escape (#867), so a value may hold any // control character; the writer escapes each one, so the text it produces is // TOML and reads back as the same value. `\0` is this reader's extension and // not TOML 1.0, so it is written as `\u0000`. -TEST(TomlSerialize, ControlCharactersAreEscaped) { +UTest control_characters_are_escaped{"TomlSerialize/ControlCharactersAreEscaped", [] { expect_round_trip(R"( [package] name = "p" @@ -143,31 +145,31 @@ description = "b\b f\f t\t n\n r\r e\u001B d\u007F z\u0000 nul\0 ué" const auto text = t::escape_string(all); for (char c : text) { const auto u = static_cast(c); - EXPECT_FALSE(u < 0x20 || u == 0x7F) << "an unescaped control character in " << text; + expect(!(u < 0x20 || u == 0x7F)) << "an unescaped control character in " << text; } - EXPECT_EQ(text.find("\\0"), std::string::npos) << "\\0 is not TOML 1.0: " << text; + expect(eq(text.find("\\0"), std::string::npos)) << "\\0 is not TOML 1.0: " << text; auto back = t::parse("v = " + text + "\n"); - ASSERT_TRUE(back.has_value()) << back.error().message << "\n" << text; - EXPECT_EQ(back->root().at("v").as_string(), all); -} + if (auto&& failed = expect(fatal(back.has_value())); !failed) failed << back.error().message << "\n" << text; + expect(eq(back->root().at("v").as_string(), all)); +}}; -TEST(TomlSerialize, RepositoryManifestsRoundTrip) { +UTest repository_manifests_round_trip{"TomlSerialize/RepositoryManifestsRoundTrip", [] { // Every manifest in the repository's examples, and the project's own. The // repository is located from this file's own path rather than from the // working directory, and the count is asserted, so a run that found no // file cannot pass as one that round-tripped them all. auto here = std::filesystem::path(std::source_location::current().file_name()); auto repo = here.parent_path().parent_path().parent_path(); - ASSERT_TRUE(std::filesystem::exists(repo / "mcpp.toml")) << repo.string(); + expect(fatal(std::filesystem::exists(repo / "mcpp.toml"))) << repo.string(); std::vector files{ repo / "mcpp.toml" }; for (auto const& e : std::filesystem::recursive_directory_iterator(repo / "examples")) if (e.is_regular_file() && e.path().filename() == "mcpp.toml") files.push_back(e.path()); - EXPECT_GE(files.size(), 10u); + expect(ge(files.size(), 10u)); for (auto const& f : files) { std::ifstream is(f, std::ios::binary); std::stringstream ss; ss << is.rdbuf(); expect_round_trip(ss.str(), f.string()); } -} +}}; diff --git a/tests/unit/test_tool_store.cpp b/tests/unit/test_tool_store.cpp index 55c9f2564..85c796746 100644 --- a/tests/unit/test_tool_store.cpp +++ b/tests/unit/test_tool_store.cpp @@ -8,12 +8,14 @@ // stamp, a change to a source moves it, and a change under the build products // does not. -#include #include import std; +import mcpplibs.mtest; import mcpp.build.tool_store; +using namespace mcpplibs::mtest; + namespace fs = std::filesystem; namespace { @@ -39,29 +41,29 @@ struct Tree { // masked by that, so every edit below also changes the length. } // namespace -TEST(ToolStoreStamp, TheSameTreeGivesTheSameStamp) { +UTest the_same_tree_gives_the_same_stamp{"ToolStoreStamp/TheSameTreeGivesTheSameStamp", [] { Tree t("mcpp_tool_store_stamp_same"); const auto a = mcpp::build::tool_store::tree_stamp(t.root); const auto b = mcpp::build::tool_store::tree_stamp(t.root); - EXPECT_EQ(a, b); - EXPECT_EQ(a.size(), 16u) << "sixteen hex digits, the fingerprint module's shape"; -} + expect(eq(a, b)); + expect(eq(a.size(), 16u)) << "sixteen hex digits, the fingerprint module's shape"; +}}; -TEST(ToolStoreStamp, AnEditToASourceMovesTheStampInBothDirections) { +UTest an_edit_to_a_source_moves_the_stamp_in_both_directions{"ToolStoreStamp/AnEditToASourceMovesTheStampInBothDirections", [] { Tree t("mcpp_tool_store_stamp_edit"); const auto before = mcpp::build::tool_store::tree_stamp(t.root); t.write("src/gen.cpp", "int main() { return 42; } // edited\n"); const auto edited = mcpp::build::tool_store::tree_stamp(t.root); - EXPECT_NE(before, edited); + expect(ne(before, edited)); // The reversal is a new state too: the file's length is back, its // modification time is not, and a key that only ever grew would miss // exactly this step. t.write("src/gen.cpp", "int main() { return 41; }\n"); const auto reverted = mcpp::build::tool_store::tree_stamp(t.root); - EXPECT_NE(edited, reverted); -} + expect(ne(edited, reverted)); +}}; -TEST(ToolStoreStamp, BuildProductsAndTheVersionControlDirectoryDoNotCount) { +UTest build_products_and_the_version_control_directory_do_not_count{"ToolStoreStamp/BuildProductsAndTheVersionControlDirectoryDoNotCount", [] { Tree t("mcpp_tool_store_stamp_target"); const auto before = mcpp::build::tool_store::tree_stamp(t.root); t.write("target/x/gen.o", "a different object of another length"); @@ -71,33 +73,33 @@ TEST(ToolStoreStamp, BuildProductsAndTheVersionControlDirectoryDoNotCount) { t.write(".mcpp/stamp", "x"); t.write("compile_commands.json", "[]"); const auto after = mcpp::build::tool_store::tree_stamp(t.root); - EXPECT_EQ(before, after) << "build products, .git, .mcpp and the compile database are excluded"; -} + expect(eq(before, after)) << "build products, .git, .mcpp and the compile database are excluded"; +}}; // #705: a consumer nested in the tool's tree -- a fixture, an example -- wrote // its own sources and outputs under the tool's root, so every build of the // consumer moved the tool's key and rebuilt the tool. A directory with its own // mcpp.toml is another package and is not this tool's input. A directory // without one is still an ordinary part of the tree. -TEST(ToolStoreStamp, ANestedPackageIsNotPartOfTheTree) { +UTest a_nested_package_is_not_part_of_the_tree{"ToolStoreStamp/ANestedPackageIsNotPartOfTheTree", [] { Tree t("mcpp_tool_store_stamp_nested"); fs::create_directories(t.root / "tests" / "consumer" / "src"); t.write("tests/consumer/mcpp.toml", "[package]\nname = \"consumer\"\n"); const auto before = mcpp::build::tool_store::tree_stamp(t.root); t.write("tests/consumer/src/main.cpp", "int main() { return 0; }\n"); t.write("tests/consumer/build.mcpp", "// generated by the consumer's prepare\n"); - EXPECT_EQ(before, mcpp::build::tool_store::tree_stamp(t.root)); + expect(eq(before, mcpp::build::tool_store::tree_stamp(t.root))); fs::create_directories(t.root / "tests" / "data"); t.write("tests/data/input.txt", "an ordinary input of the tool"); - EXPECT_NE(before, mcpp::build::tool_store::tree_stamp(t.root)); -} + expect(ne(before, mcpp::build::tool_store::tree_stamp(t.root))); +}}; // The sub-build scratch (mcpp#641, item 3). Every object path the sub-build // writes is appended to it, so it spends no length on names the entry already // records; it still separates two consumers of one entry, and a re-run by the // same consumer finds the same directory. -TEST(ToolStoreScratch, IsShortPerConsumerAndStable) { +UTest is_short_per_consumer_and_stable{"ToolStoreScratch/IsShortPerConsumerAndStable", [] { const fs::path cache{"/home/u/.mcpp/build-cache/v1"}; const fs::path entry = cache / "tool" / "mcpplibs" / "installer@0.1.0+path.e285b2f02cb1c429" / "7005eaf9bd8c80ef"; @@ -107,11 +109,11 @@ TEST(ToolStoreScratch, IsShortPerConsumerAndStable) { const auto other = mcpp::build::tool_store::scratch_dir( cache, entry.parent_path() / "0000000000000000", "/work/app"); - EXPECT_EQ(a1, a2); - EXPECT_NE(a1, b); - EXPECT_NE(a1, other); - EXPECT_EQ(a1.parent_path(), cache / "tool" / ".build"); - EXPECT_EQ(a1.filename().string().size(), 16u) << a1; + expect(eq(a1, a2)); + expect(ne(a1, b)); + expect(ne(a1, other)); + expect(eq(a1.parent_path(), cache / "tool" / ".build")); + expect(eq(a1.filename().string().size(), 16u)) << a1; // The old scratch lived inside the entry, as `/build-<16 hex>`. - EXPECT_LT(a1.generic_string().size(), (entry / "build-0123456789abcdef").generic_string().size()); -} + expect(lt(a1.generic_string().size(), (entry / "build-0123456789abcdef").generic_string().size())); +}}; diff --git a/tests/unit/test_toolchain_compat.cpp b/tests/unit/test_toolchain_compat.cpp index 9ea01d539..7ab1b9843 100644 --- a/tests/unit/test_toolchain_compat.cpp +++ b/tests/unit/test_toolchain_compat.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.platform; import mcpp.toolchain.compat; +using namespace mcpplibs::mtest; + using namespace mcpp::toolchain::compat; static std::string host_musl() { @@ -12,97 +14,97 @@ static std::string host_musl() { // ── canonical spellings pass through unchanged ─────────────────────────────── -TEST(Compat, CanonicalFamiliesPassThrough) { +UTest canonical_families_pass_through{"Compat/CanonicalFamiliesPassThrough", [] { auto g = normalize_spec("gcc", "16.1.0"); - ASSERT_TRUE(g.has_value()); - EXPECT_EQ(g->family, "gcc"); - EXPECT_EQ(g->version, "16.1.0"); - EXPECT_TRUE(g->target.empty()); - EXPECT_FALSE(g->changed); + expect(fatal(g.has_value())); + expect(eq(g->family, "gcc")); + expect(eq(g->version, "16.1.0")); + expect(g->target.empty()); + expect(!g->changed); auto l = normalize_spec("llvm", "20.1.7"); - ASSERT_TRUE(l.has_value()); - EXPECT_EQ(l->family, "llvm"); - EXPECT_FALSE(l->changed); + expect(fatal(l.has_value())); + expect(eq(l->family, "llvm")); + expect(!l->changed); auto m = normalize_spec("msvc", "system"); - ASSERT_TRUE(m.has_value()); - EXPECT_EQ(m->family, "msvc"); - EXPECT_EQ(m->version, "system"); - EXPECT_FALSE(m->changed); -} + expect(fatal(m.has_value())); + expect(eq(m->family, "msvc")); + expect(eq(m->version, "system")); + expect(!m->changed); +}}; // ── §6.2 alias table: every legacy row round-trips ─────────────────────────── -TEST(Compat, MuslVersionSuffixBecomesTarget) { +UTest musl_version_suffix_becomes_target{"Compat/MuslVersionSuffixBecomesTarget", [] { auto s = normalize_spec("gcc", "15.1.0-musl"); - ASSERT_TRUE(s.has_value()); - EXPECT_EQ(s->family, "gcc"); - EXPECT_EQ(s->version, "15.1.0"); - EXPECT_EQ(s->target.str(), host_musl()); - EXPECT_TRUE(s->changed); - EXPECT_FALSE(s->hint.empty()); -} - -TEST(Compat, MuslGccCompilerNameBecomesTarget) { + expect(fatal(s.has_value())); + expect(eq(s->family, "gcc")); + expect(eq(s->version, "15.1.0")); + expect(eq(s->target.str(), host_musl())); + expect(bool(s->changed)); + expect(!(s->hint.empty())); +}}; + +UTest musl_gcc_compiler_name_becomes_target{"Compat/MuslGccCompilerNameBecomesTarget", [] { auto s = normalize_spec("musl-gcc", "15.1.0"); - ASSERT_TRUE(s.has_value()); - EXPECT_EQ(s->family, "gcc"); - EXPECT_EQ(s->version, "15.1.0"); - EXPECT_EQ(s->target.str(), host_musl()); - EXPECT_TRUE(s->changed); -} - -TEST(Compat, TripleNamedGccBecomesTarget) { + expect(fatal(s.has_value())); + expect(eq(s->family, "gcc")); + expect(eq(s->version, "15.1.0")); + expect(eq(s->target.str(), host_musl())); + expect(bool(s->changed)); +}}; + +UTest triple_named_gcc_becomes_target{"Compat/TripleNamedGccBecomesTarget", [] { auto s = normalize_spec("aarch64-linux-musl-gcc", "16.1.0"); - ASSERT_TRUE(s.has_value()); - EXPECT_EQ(s->family, "gcc"); - EXPECT_EQ(s->version, "16.1.0"); - EXPECT_EQ(s->target.str(), "aarch64-linux-musl"); - EXPECT_TRUE(s->changed); -} - -TEST(Compat, MingwAndMingwCrossCollapseToWindowsGnu) { + expect(fatal(s.has_value())); + expect(eq(s->family, "gcc")); + expect(eq(s->version, "16.1.0")); + expect(eq(s->target.str(), "aarch64-linux-musl")); + expect(bool(s->changed)); +}}; + +UTest mingw_and_mingw_cross_collapse_to_windows_gnu{"Compat/MingwAndMingwCrossCollapseToWindowsGnu", [] { // One concept, two host-split legacy names → the SAME normalized identity. auto native = normalize_spec("mingw", "16.1.0"); auto cross = normalize_spec("mingw-cross", "16.1.0"); - ASSERT_TRUE(native.has_value()); - ASSERT_TRUE(cross.has_value()); - EXPECT_EQ(native->family, "gcc"); - EXPECT_EQ(cross->family, "gcc"); - EXPECT_EQ(native->target.str(), "x86_64-windows-gnu"); - EXPECT_EQ(cross->target.str(), "x86_64-windows-gnu"); - EXPECT_TRUE(native->changed); - EXPECT_TRUE(cross->changed); -} - -TEST(Compat, ClangAliasBecomesLlvm) { + expect(fatal(native.has_value())); + expect(fatal(cross.has_value())); + expect(eq(native->family, "gcc")); + expect(eq(cross->family, "gcc")); + expect(eq(native->target.str(), "x86_64-windows-gnu")); + expect(eq(cross->target.str(), "x86_64-windows-gnu")); + expect(bool(native->changed)); + expect(bool(cross->changed)); +}}; + +UTest clang_alias_becomes_llvm{"Compat/ClangAliasBecomesLlvm", [] { auto s = normalize_spec("clang", "20.1.7"); - ASSERT_TRUE(s.has_value()); - EXPECT_EQ(s->family, "llvm"); - EXPECT_EQ(s->version, "20.1.7"); - EXPECT_TRUE(s->changed); -} + expect(fatal(s.has_value())); + expect(eq(s->family, "llvm")); + expect(eq(s->version, "20.1.7")); + expect(bool(s->changed)); +}}; -TEST(Compat, PartialMuslVersionSuffix) { +UTest partial_musl_version_suffix{"Compat/PartialMuslVersionSuffix", [] { // "gcc 15-musl" — partial version with the legacy suffix. auto s = normalize_spec("gcc", "15-musl"); - ASSERT_TRUE(s.has_value()); - EXPECT_EQ(s->version, "15"); - EXPECT_EQ(s->target.str(), host_musl()); -} + expect(fatal(s.has_value())); + expect(eq(s->version, "15")); + expect(eq(s->target.str(), host_musl())); +}}; -TEST(Compat, XimNamespacePrefixStripped) { +UTest xim_namespace_prefix_stripped{"Compat/XimNamespacePrefixStripped", [] { auto s = normalize_spec("xim:gcc", "16.1.0"); - ASSERT_TRUE(s.has_value()); - EXPECT_EQ(s->family, "gcc"); - EXPECT_FALSE(s->changed); -} + expect(fatal(s.has_value())); + expect(eq(s->family, "gcc")); + expect(!s->changed); +}}; // ── rejects ───────────────────────────────────────────────────────────────── -TEST(Compat, RejectsUnknownFamilies) { - EXPECT_FALSE(normalize_spec("tcc", "1.0").has_value()); - EXPECT_FALSE(normalize_spec("llvm", "20.1.7-musl").has_value()); // llvm has no musl flavor - EXPECT_FALSE(normalize_spec("not-a-triple-gcc", "1.0").has_value()); -} +UTest rejects_unknown_families{"Compat/RejectsUnknownFamilies", [] { + expect(!(normalize_spec("tcc", "1.0").has_value())); + expect(!(normalize_spec("llvm", "20.1.7-musl").has_value())); // llvm has no musl flavor + expect(!(normalize_spec("not-a-triple-gcc", "1.0").has_value())); +}}; diff --git a/tests/unit/test_toolchain_descriptor.cpp b/tests/unit/test_toolchain_descriptor.cpp index 8e62d4fba..86e086ea3 100644 --- a/tests/unit/test_toolchain_descriptor.cpp +++ b/tests/unit/test_toolchain_descriptor.cpp @@ -1,11 +1,13 @@ -#include import std; +import mcpplibs.mtest; import mcpplibs.os.platform; import mcpp.toolchain.model; import mcpp.toolchain.registry; import mcpp.toolchain.triple; +using namespace mcpplibs::mtest; + using namespace mcpp::toolchain; namespace { @@ -64,20 +66,20 @@ std::string host_frontend_name() { // feature in which that stops working. The criterion has a denominator: the // SAME package object resolves the SAME frontend as before the descriptor // existed, which is what the assertion below measures. -TEST(PayloadDescriptor, NoDescriptorResolvesExactlyAsBefore) { +UTest no_descriptor_resolves_exactly_as_before{"PayloadDescriptor/NoDescriptorResolvesExactlyAsBefore", [] { FakePayload fp{"none"}; auto clang = fp.add_compiler("bin/" + host_frontend_name()); auto pkg = bin_shaped_pkg(); auto d = read_payload_descriptor(fp.root); - ASSERT_TRUE(d.has_value()) << d.error(); - EXPECT_FALSE(d->has_value()) << "no file means no answer, not an error"; + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error(); + expect(!d->has_value()) << "no file means no answer, not an error"; auto found = payload_frontend(fp.root, pkg); - ASSERT_TRUE(found.has_value()) << found.error(); - EXPECT_EQ(*found, clang); - EXPECT_EQ(payload_frontend_dir(fp.root, pkg), fp.root / "bin"); -} + if (auto&& failed = expect(fatal(found.has_value())); !failed) failed << found.error(); + expect(eq(*found, clang)); + expect(eq(payload_frontend_dir(fp.root, pkg), fp.root / "bin")); +}}; // ─── The engine stops knowing the layout (C) ──────────────────────────────── // @@ -86,7 +88,7 @@ TEST(PayloadDescriptor, NoDescriptorResolvesExactlyAsBefore) { // guess would look. Here `bin/` is empty and the compiler is five levels down // in the NDK's own shape -- so a resolution that succeeds could only have come // from the descriptor. -TEST(PayloadDescriptor, TheDescriptorAnswersWhereTheGuessCannot) { +UTest the_descriptor_answers_where_the_guess_cannot{"PayloadDescriptor/TheDescriptorAnswersWhereTheGuessCannot", [] { FakePayload fp{"frontend"}; // NOT the host-specific name, deliberately: the descriptor says exactly // where the compiler is, so the payload is free to spell it any way it @@ -103,38 +105,38 @@ TEST(PayloadDescriptor, TheDescriptorAnswersWhereTheGuessCannot) { // `some-host-tag` is not a host tag this engine can compute, which is the // point: the path is the payload's, not a derivation. auto found = payload_frontend(fp.root, pkg); - ASSERT_TRUE(found.has_value()) << found.error(); - EXPECT_EQ(*found, deep); + if (auto&& failed = expect(fatal(found.has_value())); !failed) failed << found.error(); + expect(eq(*found, deep)); // AND THE MESSAGE NAMES WHERE THE SEARCH HAPPENED. A refusal naming // `/bin` while the lookup read the descriptor's path is the failure // this repository records most often: the lookup is repaired and the // message is not. - EXPECT_EQ(payload_frontend_dir(fp.root, pkg), deep.parent_path()); -} + expect(eq(payload_frontend_dir(fp.root, pkg), deep.parent_path())); +}}; // A descriptor naming a frontend that is not there is "nothing found where we // looked" -- empty, the caller's long-standing cue -- and NOT an error. The // two outcomes have different repairs: install the payload again, versus fix // the recipe. -TEST(PayloadDescriptor, ANamedFrontendThatIsAbsentIsEmptyNotAnError) { +UTest a_named_frontend_that_is_absent_is_empty_not_an_error{"PayloadDescriptor/ANamedFrontendThatIsAbsentIsEmptyNotAnError", [] { FakePayload fp{"absent"}; fp.add_compiler("bin/" + host_frontend_name()); // the guess WOULD find this fp.write_descriptor(R"({"schema": 1, "frontend": "opt/bin/clang++"})"); auto found = payload_frontend(fp.root, bin_shaped_pkg()); - ASSERT_TRUE(found.has_value()) << found.error(); - EXPECT_TRUE(found->empty()) + if (auto&& failed = expect(fatal(found.has_value())); !failed) failed << found.error(); + expect(found->empty()) << "the payload said where its compiler is; the guess is not a " "second opinion"; -} +}}; // ─── Present and malformed is refused, naming the file (C) ────────────────── // // This is the property that makes the descriptor safe to add. Without it a typo // reads as "an older payload": the engine falls back to a hardcoded path for a // layout that has moved, and the build fails somewhere else entirely. -TEST(PayloadDescriptor, EveryMalformedShapeIsRefusedByName) { +UTest every_malformed_shape_is_refused_by_name{"PayloadDescriptor/EveryMalformedShapeIsRefusedByName", [] { struct Case { std::string_view why, json, mustMention; }; const Case cases[] = { { "not JSON", "{ not json", "JSON" }, @@ -179,25 +181,24 @@ TEST(PayloadDescriptor, EveryMalformedShapeIsRefusedByName) { fp.write_descriptor(c.json); auto d = read_payload_descriptor(fp.root); - ASSERT_FALSE(d.has_value()) << c.why << " was accepted"; + expect(fatal(!d.has_value())) << c.why << " was accepted"; // THE FILE IS NAMED IN EVERY ONE. A message that says only "invalid // descriptor" leaves the reader to find which payload wrote it. - EXPECT_NE(d.error().find(".mcpp-toolchain.json"), std::string::npos) - << c.why << ": " << d.error(); - EXPECT_NE(d.error().find(c.mustMention), std::string::npos) + expect(ne(d.error().find(".mcpp-toolchain.json"), std::string::npos)) << c.why << ": " << d.error(); + if (auto&& failed = expect(ne(d.error().find(c.mustMention), std::string::npos)); !failed) failed << c.why << ": " << d.error(); // And the refusal travels: the frontend lookup cannot express it as // "not found", so it propagates rather than falling back to the guess // -- even though the guess would have succeeded here. auto found = payload_frontend(fp.root, bin_shaped_pkg()); - ASSERT_FALSE(found.has_value()) << c.why << " fell back to the guess"; - EXPECT_EQ(found.error(), d.error()) << c.why; + expect(fatal(!found.has_value())) << c.why << " fell back to the guess"; + expect(eq(found.error(), d.error())) << c.why; } -} +}}; // ─── The three answers, read as written ───────────────────────────────────── -TEST(PayloadDescriptor, TheThreeKeysAreCarriedThrough) { +UTest the_three_keys_are_carried_through{"PayloadDescriptor/TheThreeKeysAreCarriedThrough", [] { FakePayload fp{"full"}; fp.add_compiler("toolchains/llvm/prebuilt/linux-x86_64/bin/clang++"); fp.write_descriptor(R"({ @@ -207,27 +208,26 @@ TEST(PayloadDescriptor, TheThreeKeysAreCarriedThrough) { "std_module_defines": ["__BIONIC_CTYPE_INLINE=", "_FORTIFY_SOURCE=0"] })"); auto d = read_payload_descriptor(fp.root); - ASSERT_TRUE(d.has_value()) << d.error(); - ASSERT_TRUE(d->has_value()); - EXPECT_EQ((*d)->schema, 1); - EXPECT_EQ((*d)->frontend, - "toolchains/llvm/prebuilt/linux-x86_64/bin/clang++"); - EXPECT_EQ((*d)->platformFloor, "21"); - ASSERT_EQ((*d)->stdModuleDefines.size(), 2u); - EXPECT_EQ((*d)->stdModuleDefines[0], "__BIONIC_CTYPE_INLINE="); - EXPECT_EQ((*d)->stdModuleDefines[1], "_FORTIFY_SOURCE=0"); + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error(); + expect(fatal(d->has_value())); + expect(eq((*d)->schema, 1)); + expect(eq((*d)->frontend, "toolchains/llvm/prebuilt/linux-x86_64/bin/clang++")); + expect(eq((*d)->platformFloor, "21")); + expect(fatal(eq((*d)->stdModuleDefines.size(), 2u))); + expect(eq((*d)->stdModuleDefines[0], "__BIONIC_CTYPE_INLINE=")); + expect(eq((*d)->stdModuleDefines[1], "_FORTIFY_SOURCE=0")); // ALL THREE KEYS ARE OPTIONAL. A payload that answers one question and // not the others is a payload, not a malformed file -- otherwise a second // SDK would have to fabricate answers it does not have. fp.write_descriptor(R"({"schema": 1})"); auto bare = read_payload_descriptor(fp.root); - ASSERT_TRUE(bare.has_value()) << bare.error(); - ASSERT_TRUE(bare->has_value()); - EXPECT_TRUE((*bare)->frontend.empty()); - EXPECT_TRUE((*bare)->platformFloor.empty()); - EXPECT_TRUE((*bare)->stdModuleDefines.empty()); -} + if (auto&& failed = expect(fatal(bare.has_value())); !failed) failed << bare.error(); + expect(fatal(bare->has_value())); + expect((*bare)->frontend.empty()); + expect((*bare)->platformFloor.empty()); + expect((*bare)->stdModuleDefines.empty()); +}}; // ─── Found from the compiler, by walking rather than counting ─────────────── // @@ -235,33 +235,32 @@ TEST(PayloadDescriptor, TheThreeKeysAreCarriedThrough) { // hand. Counting components is the fact that changes silently when a layout // does, so the search walks up -- and is bounded, so a compiler that belongs // to no payload cannot adopt a descriptor from some ancestor directory. -TEST(PayloadDescriptor, FoundFromTheCompilerAndTheWalkIsBounded) { +UTest found_from_the_compiler_and_the_walk_is_bounded{"PayloadDescriptor/FoundFromTheCompilerAndTheWalkIsBounded", [] { FakePayload fp{"walk"}; auto deep = fp.add_compiler( "toolchains/llvm/prebuilt/linux-x86_64/bin/clang++"); fp.write_descriptor(R"({"schema": 1, "platform_floor": "21"})"); auto d = payload_descriptor_for_compiler(deep); - ASSERT_TRUE(d.has_value()) << d.error(); - ASSERT_TRUE(d->has_value()) << "five levels up was not reached"; - EXPECT_EQ((*d)->platformFloor, "21"); + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error(); + expect(fatal(d->has_value())) << "five levels up was not reached"; + expect(eq((*d)->platformFloor, "21")); // A refusal still travels through the walk, because a compiler whose // payload describes itself wrongly is the same defect wherever it is read. fp.write_descriptor(R"({"schema": 1, "platform_floor": 21})"); auto bad = payload_descriptor_for_compiler(deep); - ASSERT_FALSE(bad.has_value()); - EXPECT_NE(bad.error().find(".mcpp-toolchain.json"), std::string::npos) - << bad.error(); + expect(fatal(!bad.has_value())); + if (auto&& failed = expect(ne(bad.error().find(".mcpp-toolchain.json"), std::string::npos)); !failed) failed << bad.error(); // Beyond the bound there is no answer. Nine levels is one past it. auto far = fp.add_compiler("a/b/c/d/e/f/g/h/i/clang++"); auto none = payload_descriptor_for_compiler(far); - ASSERT_TRUE(none.has_value()) << none.error(); - EXPECT_FALSE(none->has_value()) + if (auto&& failed = expect(fatal(none.has_value())); !failed) failed << none.error(); + expect(!none->has_value()) << "an unbounded walk adopts a descriptor from a directory that is " "not this payload"; -} +}}; // ─── The fourth key: the program that runs what the payload produces ──────── // @@ -300,27 +299,27 @@ struct FakeStore { }; } // namespace -TEST(PayloadDescriptor, TheRunnerIsCarriedThroughWithTheRootItWasReadFrom) { +UTest the_runner_is_carried_through_with_the_root_it_was_read_from{"PayloadDescriptor/TheRunnerIsCarriedThroughWithTheRootItWasReadFrom", [] { FakePayload fp{"runner"}; fp.write_descriptor(R"({"schema": 1, "runner": "bin/node"})"); auto d = read_payload_descriptor(fp.root); - ASSERT_TRUE(d.has_value()) << d.error(); - ASSERT_TRUE(d->has_value()); - EXPECT_EQ((*d)->runner, "bin/node"); - EXPECT_EQ((*d)->root, fp.root); + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << d.error(); + expect(fatal(d->has_value())); + expect(eq((*d)->runner, "bin/node")); + expect(eq((*d)->root, fp.root)); // Optional like the other three: a payload whose artefacts run natively // has no runner to name. fp.write_descriptor(R"({"schema": 1})"); auto bare = read_payload_descriptor(fp.root); - ASSERT_TRUE(bare.has_value()) << bare.error(); - ASSERT_TRUE(bare->has_value()); - EXPECT_TRUE((*bare)->runner.empty()); -} + if (auto&& failed = expect(fatal(bare.has_value())); !failed) failed << bare.error(); + expect(fatal(bare->has_value())); + expect((*bare)->runner.empty()); +}}; // THE SHAPE emsdk HAS: the compiler two levels below the root, and the runner // a DEPENDENCY's program named by its absolute path in the same store. -TEST(PayloadDescriptor, ARunnerInTheSameStoreIsTheDefaultForItsCompiler) { +UTest a_runner_in_the_same_store_is_the_default_for_its_compiler{"PayloadDescriptor/ARunnerInTheSameStoreIsTheDefaultForItsCompiler", [] { FakeStore fs{"same"}; auto emsdk = fs.payload("xim-x-emsdk", "6.0.9"); auto compiler = FakeStore::file(emsdk / "emscripten" / "em++"); @@ -330,44 +329,44 @@ TEST(PayloadDescriptor, ARunnerInTheSameStoreIsTheDefaultForItsCompiler) { node.generic_string())); auto argv = payload_default_runner(compiler); - ASSERT_EQ(argv.size(), 1u); - EXPECT_EQ(std::filesystem::path(argv[0]), std::filesystem::weakly_canonical(node)); + expect(fatal(eq(argv.size(), 1u))); + expect(eq(std::filesystem::path(argv[0]), std::filesystem::weakly_canonical(node))); // The relative form resolves against the root the descriptor came from. FakeStore::describe(emsdk, R"({"schema": 1, "runner": "bin/node"})"); auto rel = payload_default_runner(compiler); - ASSERT_EQ(rel.size(), 1u); - EXPECT_EQ(std::filesystem::path(rel[0]), emsdk / "bin/node"); -} + expect(fatal(eq(rel.size(), 1u))); + expect(eq(std::filesystem::path(rel[0]), emsdk / "bin/node")); +}}; // WHAT THE STORE RULE REMOVES: a payload choosing a host interpreter. Ignored // rather than refused, so a machine whose store is elsewhere does not lose its // build over a run-time default. -TEST(PayloadDescriptor, ARunnerOutsideTheStoreIsNotHonoured) { +UTest a_runner_outside_the_store_is_not_honoured{"PayloadDescriptor/ARunnerOutsideTheStoreIsNotHonoured", [] { FakeStore fs{"outside"}; auto emsdk = fs.payload("xim-x-emsdk", "6.0.9"); auto compiler = FakeStore::file(emsdk / "emscripten" / "em++"); FakeStore::describe(emsdk, R"({"schema": 1, "runner": "/usr/bin/node"})"); auto d = read_payload_descriptor(emsdk); - ASSERT_TRUE(d.has_value()) << "an absolute runner is well formed: " << d.error(); - EXPECT_TRUE(payload_default_runner(compiler).empty()) + if (auto&& failed = expect(fatal(d.has_value())); !failed) failed << "an absolute runner is well formed: " << d.error(); + expect(payload_default_runner(compiler).empty()) << "a runner outside the payload's store was honoured"; -} +}}; -TEST(PayloadDescriptor, NoDescriptorOrNoRunnerMeansNoDefault) { +UTest no_descriptor_or_no_runner_means_no_default{"PayloadDescriptor/NoDescriptorOrNoRunnerMeansNoDefault", [] { FakeStore fs{"none"}; auto llvm = fs.payload("xim-x-llvm", "22.1.8"); auto compiler = FakeStore::file(llvm / "bin" / "clang++"); - EXPECT_TRUE(payload_default_runner(compiler).empty()); + expect(payload_default_runner(compiler).empty()); FakeStore::describe(llvm, R"({"schema": 1, "platform_floor": "21"})"); - EXPECT_TRUE(payload_default_runner(compiler).empty()); + expect(payload_default_runner(compiler).empty()); // And a malformed descriptor is absence here; the build refused it first. FakeStore::describe(llvm, R"({"schema": 1, "runner": 7})"); - EXPECT_TRUE(payload_default_runner(compiler).empty()); -} + expect(payload_default_runner(compiler).empty()); +}}; -TEST(PayloadDescriptor, AMalformedRunnerIsRefusedByNameAndByKey) { +UTest a_malformed_runner_is_refused_by_name_and_by_key{"PayloadDescriptor/AMalformedRunnerIsRefusedByNameAndByKey", [] { FakePayload fp{"badrunner"}; for (std::string_view json : { std::string_view(R"({"schema": 1, "runner": 7})"), @@ -380,8 +379,8 @@ TEST(PayloadDescriptor, AMalformedRunnerIsRefusedByNameAndByKey) { }) { fp.write_descriptor(json); auto d = read_payload_descriptor(fp.root); - ASSERT_FALSE(d.has_value()) << "accepted: " << json; - EXPECT_NE(d.error().find("\"runner\""), std::string::npos) << d.error(); - EXPECT_NE(d.error().find(".mcpp-toolchain.json"), std::string::npos) << d.error(); + expect(fatal(!d.has_value())) << "accepted: " << json; + if (auto&& failed = expect(ne(d.error().find("\"runner\""), std::string::npos)); !failed) failed << d.error(); + if (auto&& failed = expect(ne(d.error().find(".mcpp-toolchain.json"), std::string::npos)); !failed) failed << d.error(); } -} +}}; diff --git a/tests/unit/test_toolchain_detect.cpp b/tests/unit/test_toolchain_detect.cpp index 9cedb79cd..fea9f9a8e 100644 --- a/tests/unit/test_toolchain_detect.cpp +++ b/tests/unit/test_toolchain_detect.cpp @@ -1,10 +1,12 @@ -#include import std; +import mcpplibs.mtest; import mcpp.invocation; import mcpp.toolchain.detect; import mcpp.toolchain.probe; +using namespace mcpplibs::mtest; + using namespace mcpp::toolchain; namespace { @@ -65,22 +67,22 @@ std::filesystem::path make_hostile_ld_dir() { #if !defined(_WIN32) // Uses a fake shell script as a compiler — POSIX only. -TEST(ToolchainDetect, ClangVersionOutputIsNotMisclassifiedByGccPaths) { +UTest clang_version_output_is_not_misclassified_by_gcc_paths{"ToolchainDetect/ClangVersionOutputIsNotMisclassifiedByGccPaths", [] { auto clang = make_fake_clang(); TempDirGuard cleanup{clang.parent_path()}; auto tc = detect(clang); - ASSERT_TRUE(tc.has_value()) << tc.error().message; - EXPECT_EQ(tc->compiler, CompilerId::Clang); - EXPECT_EQ(tc->version, "20.1.7"); - EXPECT_EQ(tc->targetTriple, "x86_64-unknown-linux-gnu"); - EXPECT_EQ(tc->stdlibId, "libc++"); - EXPECT_FALSE(tc->hasImportStd); -} + if (auto&& failed = expect(fatal(tc.has_value())); !failed) failed << tc.error().message; + expect(eq(tc->compiler, CompilerId::Clang)); + expect(eq(tc->version, "20.1.7")); + expect(eq(tc->targetTriple, "x86_64-unknown-linux-gnu")); + expect(eq(tc->stdlibId, "libc++")); + expect(!tc->hasImportStd); +}}; #endif // !defined(_WIN32) #if defined(__linux__) -TEST(ToolchainDetect, IgnoresTargetRuntimeLibraryPathDuringProbe) { +UTest ignores_target_runtime_library_path_during_probe{"ToolchainDetect/IgnoresTargetRuntimeLibraryPathDuringProbe", [] { auto clang = make_fake_clang(); TempDirGuard cleanup_clang{clang.parent_path()}; auto hostile = make_hostile_ld_dir(); @@ -89,10 +91,10 @@ TEST(ToolchainDetect, IgnoresTargetRuntimeLibraryPathDuringProbe) { mcpp::invocation::ScopedEnv ld("LD_LIBRARY_PATH", hostile.string()); auto tc = detect(clang); - ASSERT_TRUE(tc.has_value()) << tc.error().message; - EXPECT_EQ(tc->compiler, CompilerId::Clang); - EXPECT_EQ(tc->targetTriple, "x86_64-unknown-linux-gnu"); -} + if (auto&& failed = expect(fatal(tc.has_value())); !failed) failed << tc.error().message; + expect(eq(tc->compiler, CompilerId::Clang)); + expect(eq(tc->targetTriple, "x86_64-unknown-linux-gnu")); +}}; #endif // defined(__linux__) // ─── normalize_driver_output: path-free semantic identity ───────────── @@ -104,7 +106,7 @@ TEST(ToolchainDetect, IgnoresTargetRuntimeLibraryPathDuringProbe) { // etc.) yet behave identically. We now use a normalized `--version` // string as the path-free identity instead. -TEST(NormalizeDriverOutput, TrimsWhitespaceAndCollapsesBlankLines) { +UTest trims_whitespace_and_collapses_blank_lines{"NormalizeDriverOutput/TrimsWhitespaceAndCollapsesBlankLines", [] { std::string raw = " g++ (xim-x-gcc 16.1.0) 16.1.0\n" "\n" @@ -112,13 +114,12 @@ TEST(NormalizeDriverOutput, TrimsWhitespaceAndCollapsesBlankLines) { "\n\n" "This is free software; ...\n"; auto out = normalize_driver_output(raw); - EXPECT_EQ(out, - "g++ (xim-x-gcc 16.1.0) 16.1.0\n" + expect(eq(out, "g++ (xim-x-gcc 16.1.0) 16.1.0\n" "Copyright (C) 2023 Free Software Foundation, Inc.\n" - "This is free software; ..."); -} + "This is free software; ...")); +}}; -TEST(NormalizeDriverOutput, IsStableAcrossInstallPrefixes) { +UTest is_stable_across_install_prefixes{"NormalizeDriverOutput/IsStableAcrossInstallPrefixes", [] { // Same gcc package, two different install locations on disk. // --version output is identical regardless of where the binary lives, // so normalized identity must be identical too. @@ -128,10 +129,10 @@ TEST(NormalizeDriverOutput, IsStableAcrossInstallPrefixes) { std::string from_b = "g++ (xim-x-gcc 16.1.0) 16.1.0\n" "Copyright (C) 2023 Free Software Foundation, Inc.\n"; - EXPECT_EQ(normalize_driver_output(from_a), normalize_driver_output(from_b)); -} + expect(eq(normalize_driver_output(from_a), normalize_driver_output(from_b))); +}}; -TEST(NormalizeDriverOutput, ReplacesLocalInstallPaths) { +UTest replaces_local_install_paths{"NormalizeDriverOutput/ReplacesLocalInstallPaths", [] { std::string a = "clang version 20.1.7\n" "Configuration file: /home/speak/.mcpp/registry/data/xpkgs/llvm/bin/clang.cfg\n"; @@ -139,32 +140,31 @@ TEST(NormalizeDriverOutput, ReplacesLocalInstallPaths) { "clang version 20.1.7\n" "Configuration file: /home/speak/.xlings/data/xpkgs/llvm/bin/clang.cfg\n"; - EXPECT_EQ(normalize_driver_output(a), normalize_driver_output(b)); - EXPECT_EQ(normalize_driver_output(a).find("/home/"), std::string::npos); -} + expect(eq(normalize_driver_output(a), normalize_driver_output(b))); + expect(eq(normalize_driver_output(a).find("/home/"), std::string::npos)); +}}; -TEST(NormalizeDriverOutput, DistinguishesDifferentVersions) { +UTest distinguishes_different_versions{"NormalizeDriverOutput/DistinguishesDifferentVersions", [] { std::string a = "g++ (xim-x-gcc 16.1.0) 16.1.0\n"; std::string b = "g++ (xim-x-gcc 15.1.0) 15.1.0\n"; - EXPECT_NE(normalize_driver_output(a), normalize_driver_output(b)); -} + expect(ne(normalize_driver_output(a), normalize_driver_output(b))); +}}; -TEST(NormalizeDriverOutput, EmptyInputProducesEmpty) { - EXPECT_EQ(normalize_driver_output(""), ""); - EXPECT_EQ(normalize_driver_output("\n\n\n"), ""); -} +UTest empty_input_produces_empty{"NormalizeDriverOutput/EmptyInputProducesEmpty", [] { + expect(eq(normalize_driver_output(""), "")); + expect(eq(normalize_driver_output("\n\n\n"), "")); +}}; #if !defined(_WIN32) // ─── detect() populates driverIdent ───────────────────────────────── -TEST(ToolchainDetect, PopulatesDriverIdentFromVersionOutput) { +UTest populates_driver_ident_from_version_output{"ToolchainDetect/PopulatesDriverIdentFromVersionOutput", [] { auto clang = make_fake_clang(); TempDirGuard cleanup{clang.parent_path()}; auto tc = detect(clang); - ASSERT_TRUE(tc.has_value()) << tc.error().message; - EXPECT_FALSE(tc->driverIdent.empty()) + if (auto&& failed = expect(fatal(tc.has_value())); !failed) failed << tc.error().message; + expect(!(tc->driverIdent.empty())) << "detect() should populate Toolchain::driverIdent from --version output"; - EXPECT_NE(tc->driverIdent.find("clang version 20.1.7"), std::string::npos) - << "driverIdent should contain the --version header: " << tc->driverIdent; -} + if (auto&& failed = expect(ne(tc->driverIdent.find("clang version 20.1.7"), std::string::npos)); !failed) failed << "driverIdent should contain the --version header: " << tc->driverIdent; +}}; #endif // !defined(_WIN32) diff --git a/tests/unit/test_toolchain_dialect.cpp b/tests/unit/test_toolchain_dialect.cpp index 77200150c..94f50f789 100644 --- a/tests/unit/test_toolchain_dialect.cpp +++ b/tests/unit/test_toolchain_dialect.cpp @@ -1,11 +1,13 @@ -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.toolchain.dialect; import mcpp.toolchain.model; import mcpp.toolchain.registry; +using namespace mcpplibs::mtest; + using namespace mcpp::toolchain; namespace { @@ -17,170 +19,168 @@ Toolchain make_tc(CompilerId id, std::string triple = {}) { } } // namespace -TEST(CommandDialect, GnuSpellings) { +UTest gnu_spellings{"CommandDialect/GnuSpellings", [] { auto tc = make_tc(CompilerId::GCC); const auto& d = dialect_for(tc); - EXPECT_EQ(d.id, "gnu"); - EXPECT_EQ(d.includePrefix, "-I"); - EXPECT_EQ(d.stdPrefix, "-std="); - EXPECT_EQ(d.outputObjPrefix, "-o "); - EXPECT_EQ(d.objExt, ".o"); - EXPECT_EQ(d.archiveCmd, "$ar rcs $out $in"); - EXPECT_FALSE(d.rspfileLink); - EXPECT_EQ(d.linkStyle, CommandDialect::LinkStyle::Driver); + expect(eq(d.id, "gnu")); + expect(eq(d.includePrefix, "-I")); + expect(eq(d.stdPrefix, "-std=")); + expect(eq(d.outputObjPrefix, "-o ")); + expect(eq(d.objExt, ".o")); + expect(eq(d.archiveCmd, "$ar rcs $out $in")); + expect(!d.rspfileLink); + expect(eq(d.linkStyle, CommandDialect::LinkStyle::Driver)); // Clang and MinGW share the gnu row. - EXPECT_EQ(dialect_for(make_tc(CompilerId::Clang)).id, "gnu"); - EXPECT_EQ(dialect_for(make_tc(CompilerId::GCC, "x86_64-w64-mingw32")).id, "gnu"); -} + expect(eq(dialect_for(make_tc(CompilerId::Clang)).id, "gnu")); + expect(eq(dialect_for(make_tc(CompilerId::GCC, "x86_64-w64-mingw32")).id, "gnu")); +}}; -TEST(CommandDialect, MsvcSpellings) { +UTest msvc_spellings{"CommandDialect/MsvcSpellings", [] { const auto& d = dialect_for(make_tc(CompilerId::MSVC)); - EXPECT_EQ(d.id, "msvc"); - EXPECT_EQ(d.includePrefix, "/I"); - EXPECT_EQ(d.stdPrefix, "/std:"); - EXPECT_EQ(d.outputObjPrefix, "/Fo:"); - EXPECT_EQ(d.objExt, ".obj"); + expect(eq(d.id, "msvc")); + expect(eq(d.includePrefix, "/I")); + expect(eq(d.stdPrefix, "/std:")); + expect(eq(d.outputObjPrefix, "/Fo:")); + expect(eq(d.objExt, ".obj")); // Each object carries its debug information: no compile writes through // the shared PDB server another compile's unit holds. - EXPECT_EQ(d.debugFlags, "/Z7"); - EXPECT_TRUE(d.rspfileLink); - EXPECT_EQ(d.linkStyle, CommandDialect::LinkStyle::SeparateLinker); -} + expect(eq(d.debugFlags, "/Z7")); + expect(bool(d.rspfileLink)); + expect(eq(d.linkStyle, CommandDialect::LinkStyle::SeparateLinker)); +}}; -TEST(BmiTraitsSpellings, ModuleFlagRowsAreConsistent) { +UTest module_flag_rows_are_consistent{"BmiTraitsSpellings/ModuleFlagRowsAreConsistent", [] { auto gcc = bmi_traits(make_tc(CompilerId::GCC)); - EXPECT_EQ(gcc.compileModulesFlag, " -fmodules"); - EXPECT_TRUE(gcc.stdBmiUsePrefix.empty()); - EXPECT_TRUE(gcc.moduleOutputPrefix.empty()); + expect(eq(gcc.compileModulesFlag, " -fmodules")); + expect(gcc.stdBmiUsePrefix.empty()); + expect(gcc.moduleOutputPrefix.empty()); auto clang = bmi_traits(make_tc(CompilerId::Clang)); - EXPECT_TRUE(clang.compileModulesFlag.empty()); - EXPECT_EQ(clang.stdBmiUsePrefix, " -fmodule-file=std="); - EXPECT_EQ(clang.moduleOutputPrefix, " -fmodule-output="); - EXPECT_EQ(clang.bmiSearchPrefix, " -fprebuilt-module-path="); + expect(clang.compileModulesFlag.empty()); + expect(eq(clang.stdBmiUsePrefix, " -fmodule-file=std=")); + expect(eq(clang.moduleOutputPrefix, " -fmodule-output=")); + expect(eq(clang.bmiSearchPrefix, " -fprebuilt-module-path=")); auto msvc = bmi_traits(make_tc(CompilerId::MSVC)); - EXPECT_EQ(msvc.stdBmiUsePrefix, " /reference std="); - EXPECT_EQ(msvc.moduleOutputPrefix, " /ifcOutput "); - EXPECT_EQ(msvc.bmiSearchPrefix, " /ifcSearchDir "); -} + expect(eq(msvc.stdBmiUsePrefix, " /reference std=")); + expect(eq(msvc.moduleOutputPrefix, " /ifcOutput ")); + expect(eq(msvc.bmiSearchPrefix, " /ifcSearchDir ")); +}}; -TEST(MingwSpec, LegacyMingwSpellingIsGccTargetingWindowsGnu) { +UTest legacy_mingw_spelling_is_gcc_targeting_windows_gnu{"MingwSpec/LegacyMingwSpellingIsGccTargetingWindowsGnu", [] { // `mingw` is no longer a family: it normalizes to gcc targeting // x86_64-windows-gnu. Which payload serves it is host-split at the // distribution layer (winlibs mingw-gcc on Windows, mingw-cross-gcc // elsewhere) — the user-facing identity is the same either way. auto spec = parse_toolchain_spec("mingw@16.1.0"); - ASSERT_TRUE(spec.has_value()); - EXPECT_FALSE(is_system_toolchain(*spec)); - EXPECT_EQ(spec->family, Family::Gcc); - EXPECT_EQ(spec->target.str(), "x86_64-windows-gnu"); - EXPECT_EQ(spec->display(), "gcc@16.1.0 → x86_64-windows-gnu"); + expect(fatal(spec.has_value())); + expect(!is_system_toolchain(*spec)); + expect(eq(spec->family, Family::Gcc)); + expect(eq(spec->target.str(), "x86_64-windows-gnu")); + expect(eq(spec->display(), "gcc@16.1.0 → x86_64-windows-gnu")); auto pkg = to_xim_package(*spec); - EXPECT_EQ(pkg.ximVersion, "16.1.0"); - ASSERT_FALSE(pkg.frontendCandidates.empty()); + expect(eq(pkg.ximVersion, "16.1.0")); + expect(fatal(!pkg.frontendCandidates.empty())); #if defined(_WIN32) - EXPECT_EQ(pkg.ximName, "mingw-gcc"); - EXPECT_EQ(pkg.frontendCandidates.front(), "g++.exe"); + expect(eq(pkg.ximName, "mingw-gcc")); + expect(eq(pkg.frontendCandidates.front(), "g++.exe")); #else - EXPECT_EQ(pkg.ximName, "mingw-cross-gcc"); - EXPECT_EQ(pkg.frontendCandidates.front(), "x86_64-w64-mingw32-g++"); + expect(eq(pkg.ximName, "mingw-cross-gcc")); + expect(eq(pkg.frontendCandidates.front(), "x86_64-w64-mingw32-g++")); #endif - EXPECT_FALSE(pkg.needsGccPostInstallFixup); -} + expect(!pkg.needsGccPostInstallFixup); +}}; -TEST(MingwSpec, PayloadIdentityAndDefaultMatching) { +UTest payload_identity_and_default_matching{"MingwSpec/PayloadIdentityAndDefaultMatching", [] { // Both host-split payload dirs identify as the SAME (gcc, windows-gnu). for (auto dir : {"mingw-gcc", "mingw-cross-gcc"}) { auto id = identify_xim_payload(dir); - ASSERT_TRUE(id.has_value()) << dir; - EXPECT_EQ(id->family, Family::Gcc) << dir; - EXPECT_EQ(id->target.str(), "x86_64-windows-gnu") << dir; + expect(fatal(id.has_value())) << dir; + expect(eq(id->family, Family::Gcc)) << dir; + expect(eq(id->target.str(), "x86_64-windows-gnu")) << dir; } auto id = *identify_xim_payload("mingw-cross-gcc"); auto def = parse_toolchain_spec("mingw-cross@16.1.0"); // legacy default string - ASSERT_TRUE(def.has_value()); - EXPECT_TRUE(spec_matches_payload(*def, id, "16.1.0")); - EXPECT_FALSE(spec_matches_payload(*def, id, "15.1.0")); + expect(fatal(def.has_value())); + expect(bool(spec_matches_payload(*def, id, "16.1.0"))); + expect(!spec_matches_payload(*def, id, "15.1.0")); auto llvmDef = parse_toolchain_spec("llvm@16.1.0"); - ASSERT_TRUE(llvmDef.has_value()); - EXPECT_FALSE(spec_matches_payload(*llvmDef, id, "16.1.0")); -} + expect(fatal(llvmDef.has_value())); + expect(!spec_matches_payload(*llvmDef, id, "16.1.0")); +}}; -TEST(StdFlagFor, PerDialectSpelling) { +UTest per_dialect_spelling{"StdFlagFor/PerDialectSpelling", [] { const auto& gnu = dialect_for(make_tc(CompilerId::GCC)); const auto& msvc = dialect_for(make_tc(CompilerId::MSVC)); - EXPECT_EQ(std_flag_for(gnu, "c++26", 26), "-std=c++26"); - EXPECT_EQ(std_flag_for(gnu, "gnu++23", 23), "-std=gnu++23"); - EXPECT_EQ(std_flag_for(gnu, "c++20", 20), "-std=c++20"); - EXPECT_EQ(std_flag_for(gnu, "gnu++20", 20), "-std=gnu++20"); + expect(eq(std_flag_for(gnu, "c++26", 26), "-std=c++26")); + expect(eq(std_flag_for(gnu, "gnu++23", 23), "-std=gnu++23")); + expect(eq(std_flag_for(gnu, "c++20", 20), "-std=c++20")); + expect(eq(std_flag_for(gnu, "gnu++20", 20), "-std=gnu++20")); // The `level <= 20` row: unreachable until c++20 entered the allow-list, // because 23 was the lowest level a manifest could produce. - EXPECT_EQ(std_flag_for(msvc, "c++20", 20), "/std:c++20"); - EXPECT_EQ(std_flag_for(msvc, "gnu++20", 20), "/std:c++20"); // no MSVC gnu dialect - EXPECT_EQ(std_flag_for(msvc, "c++23", 23), "/std:c++latest"); - EXPECT_EQ(std_flag_for(msvc, "c++26", 26), "/std:c++latest"); -} + expect(eq(std_flag_for(msvc, "c++20", 20), "/std:c++20")); + expect(eq(std_flag_for(msvc, "gnu++20", 20), "/std:c++20")); // no MSVC gnu dialect + expect(eq(std_flag_for(msvc, "c++23", 23), "/std:c++latest")); + expect(eq(std_flag_for(msvc, "c++26", 26), "/std:c++latest")); +}}; // ─── issue #210: dialect-class flag extraction ─────────────────────────── -TEST(DialectFlags, KnownListAndEscapeHatch) { +UTest known_list_and_escape_hatch{"DialectFlags/KnownListAndEscapeHatch", [] { mcpp::manifest::BuildConfig bc; bc.cxxflags = {"-freflection", "-O3", "-Wall", "-D_GLIBCXX_USE_CXX11_ABI=0"}; bc.dialectCxxflags = {"-fcustom-std-thing", "-freflection"}; // dup dedups auto flags = mcpp::manifest::dialect_flags(bc); - ASSERT_EQ(flags.size(), 3u); - EXPECT_EQ(flags[0], "-fcustom-std-thing"); // explicit first, order kept - EXPECT_EQ(flags[1], "-freflection"); // deduped against cxxflags copy - EXPECT_EQ(flags[2], "-D_GLIBCXX_USE_CXX11_ABI=0"); // auto-promoted -} + expect(fatal(eq(flags.size(), 3u))); + expect(eq(flags[0], "-fcustom-std-thing")); // explicit first, order kept + expect(eq(flags[1], "-freflection")); // deduped against cxxflags copy + expect(eq(flags[2], "-D_GLIBCXX_USE_CXX11_ABI=0")); // auto-promoted +}}; -TEST(DialectFlags, ExtractionDetails) { +UTest extraction_details{"DialectFlags/ExtractionDetails", [] { using mcpp::manifest::is_dialect_flag; - EXPECT_TRUE(is_dialect_flag("-freflection")); - EXPECT_TRUE(is_dialect_flag("-fcontracts")); - EXPECT_TRUE(is_dialect_flag("-fno-char8_t")); - EXPECT_TRUE(is_dialect_flag("-D_GLIBCXX_USE_CXX11_ABI=1")); + expect(is_dialect_flag("-freflection")); + expect(is_dialect_flag("-fcontracts")); + expect(is_dialect_flag("-fno-char8_t")); + expect(is_dialect_flag("-D_GLIBCXX_USE_CXX11_ABI=1")); // Conservative first list: these stay per-unit. - EXPECT_FALSE(is_dialect_flag("-fno-exceptions")); - EXPECT_FALSE(is_dialect_flag("-fno-rtti")); - EXPECT_FALSE(is_dialect_flag("-O2")); - EXPECT_FALSE(is_dialect_flag("-Wall")); - EXPECT_FALSE(is_dialect_flag("-fPIC")); -} + expect(!is_dialect_flag("-fno-exceptions")); + expect(!is_dialect_flag("-fno-rtti")); + expect(!is_dialect_flag("-O2")); + expect(!is_dialect_flag("-Wall")); + expect(!is_dialect_flag("-fPIC")); +}}; -TEST(MingwModel, TargetPredicate) { - EXPECT_TRUE(is_mingw_target(make_tc(CompilerId::GCC, "x86_64-w64-mingw32"))); - EXPECT_FALSE(is_mingw_target(make_tc(CompilerId::GCC, "x86_64-linux-gnu"))); - EXPECT_FALSE(is_mingw_target(make_tc(CompilerId::Clang, "x86_64-pc-windows-msvc"))); -} +UTest target_predicate{"MingwModel/TargetPredicate", [] { + expect(is_mingw_target(make_tc(CompilerId::GCC, "x86_64-w64-mingw32"))); + expect(!(is_mingw_target(make_tc(CompilerId::GCC, "x86_64-linux-gnu")))); + expect(!(is_mingw_target(make_tc(CompilerId::Clang, "x86_64-pc-windows-msvc")))); +}}; // ─── #718: the CRT model reaches every MSVC-ABI row, cl and clang++ alike ── // The property the whole design rests on: one helper, `msvc_abi_crt_word`, // spells the SAME two-state switch (static/dynamic CRT) for whichever driver // `tc` is, and says nothing for a row that is not the MSVC ABI at all. -TEST(MsvcAbiCrtWord, SpelledForEachDriverAndEmptyOffTheAbi) { +UTest spelled_for_each_driver_and_empty_off_the_abi{"MsvcAbiCrtWord/SpelledForEachDriverAndEmptyOffTheAbi", [] { auto cl = make_tc(CompilerId::MSVC, "x86_64-pc-windows-msvc"); - EXPECT_EQ(msvc_abi_crt_word(cl, /*staticCrt=*/true), "/MT"); - EXPECT_EQ(msvc_abi_crt_word(cl, /*staticCrt=*/false), "/MD"); + expect(eq(msvc_abi_crt_word(cl, /*staticCrt=*/true), "/MT")); + expect(eq(msvc_abi_crt_word(cl, /*staticCrt=*/false), "/MD")); auto llvmRow = make_tc(CompilerId::Clang, "x86_64-pc-windows-msvc"); - EXPECT_EQ(msvc_abi_crt_word(llvmRow, /*staticCrt=*/true), - "-fms-runtime-lib=static"); - EXPECT_EQ(msvc_abi_crt_word(llvmRow, /*staticCrt=*/false), - "-fms-runtime-lib=dll"); + expect(eq(msvc_abi_crt_word(llvmRow, /*staticCrt=*/true), "-fms-runtime-lib=static")); + expect(eq(msvc_abi_crt_word(llvmRow, /*staticCrt=*/false), "-fms-runtime-lib=dll")); // Neither the MSVC ABI: MinGW links the MSVC CRT to no row at all, and a // plain Linux row has no CRT axis of this kind either. - EXPECT_TRUE(msvc_abi_crt_word( + expect(msvc_abi_crt_word( make_tc(CompilerId::GCC, "x86_64-w64-mingw32"), true).empty()); - EXPECT_TRUE(msvc_abi_crt_word( + expect(msvc_abi_crt_word( make_tc(CompilerId::Clang, "x86_64-linux-gnu"), false).empty()); - EXPECT_TRUE(msvc_abi_crt_word( + expect(msvc_abi_crt_word( make_tc(CompilerId::GCC, "x86_64-linux-gnu"), true).empty()); -} +}}; // Every MSVC-ABI row x {undeclared, self-contained, toolchain-coupled, // host-coupled, linkage=static} yields exactly one CRT word, spelled for its @@ -191,7 +191,7 @@ TEST(MsvcAbiCrtWord, SpelledForEachDriverAndEmptyOffTheAbi) { // against the dynamic CRT; they differ only in whether the redistributable // is staged, which is a distribution.cppm/flags.cppm concern this helper does // not carry. -TEST(MsvcAbiCrtWord, EveryContractResolvesToExactlyOneWordPerRow) { +UTest every_contract_resolves_to_exactly_one_word_per_row{"MsvcAbiCrtWord/EveryContractResolvesToExactlyOneWordPerRow", [] { struct Row { Toolchain tc; std::string_view label; }; Row rows[] = { {make_tc(CompilerId::MSVC, "x86_64-pc-windows-msvc"), "cl"}, @@ -211,64 +211,63 @@ TEST(MsvcAbiCrtWord, EveryContractResolvesToExactlyOneWordPerRow) { for (auto& row : rows) { for (auto& c : cases) { const bool staticCrt = msvc_wants_static_crt(c.linkage, c.cxxRuntime); - EXPECT_EQ(staticCrt, c.wantsStatic) + expect(eq(staticCrt, c.wantsStatic)) << row.label << " linkage='" << c.linkage << "' cxx_runtime='" << c.cxxRuntime << "'"; auto word = msvc_abi_crt_word(row.tc, staticCrt); - EXPECT_FALSE(word.empty()) << row.label; + expect(!word.empty()) << row.label; // Exactly one of the two spellings for this driver, and it is // the SAME word `flags.cppm`'s compile line and `scan.cpp`'s std // BMI mirror both call this helper for — one derivation, so a TU // and its std BMI cannot disagree by construction. if (row.tc.compiler == CompilerId::MSVC) { - EXPECT_EQ(word, staticCrt ? "/MT" : "/MD") << row.label; + expect(eq(word, staticCrt ? "/MT" : "/MD")) << row.label; } else { - EXPECT_EQ(word, staticCrt ? "-fms-runtime-lib=static" - : "-fms-runtime-lib=dll") << row.label; + expect(eq(word, staticCrt ? "-fms-runtime-lib=static" + : "-fms-runtime-lib=dll")) << row.label; } } } -} +}}; // A shared-PDB word is refused on cl.exe, either dash; other words pass. -TEST(CheckSharedPdbWord, ZiZIAndFSAreRefusedOtherWordsPass) { +UTest zi_zi_and_fs_are_refused_other_words_pass{"CheckSharedPdbWord/ZiZIAndFSAreRefusedOtherWordsPass", [] { for (auto* word : {"/Zi", "-Zi", "/ZI", "-ZI", "/FS", "-FS"}) { auto refusal = check_shared_pdb_word(word, "[build] cxxflags"); - ASSERT_TRUE(refusal.has_value()) << word; - EXPECT_NE(refusal->find(word), std::string::npos) << *refusal; - EXPECT_NE(refusal->find("[build] cxxflags"), std::string::npos) << *refusal; - EXPECT_NE(refusal->find("/Z7"), std::string::npos) << *refusal; + expect(fatal(refusal.has_value())) << word; + if (auto&& failed = expect(ne(refusal->find(word), std::string::npos)); !failed) failed << *refusal; + if (auto&& failed = expect(ne(refusal->find("[build] cxxflags"), std::string::npos)); !failed) failed << *refusal; + if (auto&& failed = expect(ne(refusal->find("/Z7"), std::string::npos)); !failed) failed << *refusal; } for (auto* word : {"/Z7", "-Z7", "/Zc:__cplusplus", "/Zp8", "/FSx", "/MT", "-g"}) - EXPECT_FALSE(check_shared_pdb_word(word, "[build] cxxflags").has_value()) << word; -} + expect(!(check_shared_pdb_word(word, "[build] cxxflags").has_value())) << word; +}}; // D3: a free-form CRT word is always a second statement once the model is // resolved for every MSVC-ABI row. -TEST(CheckCrtWord, AgreeingWordIsRedundantContradictingWordIsRefused) { +UTest agreeing_word_is_redundant_contradicting_word_is_refused{"CheckCrtWord/AgreeingWordIsRedundantContradictingWordIsRefused", [] { // Recognised spellings, both dash conventions and both drivers. for (auto* word : {"/MT", "-MT", "-fms-runtime-lib=static"}) { auto agree = check_crt_word(word, /*staticCrt=*/true, "[build] cxxflags"); - ASSERT_TRUE(agree.has_value()) << word; - EXPECT_FALSE(agree->contradicts) << word; + expect(fatal(agree.has_value())) << word; + expect(!agree->contradicts) << word; // The key it names states the same model. - EXPECT_NE(agree->message.find("self-contained"), std::string::npos) << agree->message; + expect(ne(agree->message.find("self-contained"), std::string::npos)) << agree->message; auto disagree = check_crt_word(word, /*staticCrt=*/false, "[build] cxxflags"); - ASSERT_TRUE(disagree.has_value()) << word; - EXPECT_TRUE(disagree->contradicts) << word; - EXPECT_NE(disagree->message.find(word), std::string::npos) << disagree->message; - EXPECT_NE(disagree->message.find("[build] cxxflags"), std::string::npos) - << disagree->message; + expect(fatal(disagree.has_value())) << word; + expect(bool(disagree->contradicts)) << word; + if (auto&& failed = expect(ne(disagree->message.find(word), std::string::npos)); !failed) failed << disagree->message; + if (auto&& failed = expect(ne(disagree->message.find("[build] cxxflags"), std::string::npos)); !failed) failed << disagree->message; } for (auto* word : {"/MD", "-MD", "-fms-runtime-lib=dll"}) { auto agree = check_crt_word(word, /*staticCrt=*/false, "dialect_cxxflags"); - ASSERT_TRUE(agree.has_value()) << word; - EXPECT_FALSE(agree->contradicts) << word; - EXPECT_NE(agree->message.find("toolchain-coupled"), std::string::npos) << agree->message; - EXPECT_EQ(agree->message.find("self-contained"), std::string::npos) << agree->message; + expect(fatal(agree.has_value())) << word; + expect(!agree->contradicts) << word; + if (auto&& failed = expect(ne(agree->message.find("toolchain-coupled"), std::string::npos)); !failed) failed << agree->message; + if (auto&& failed = expect(eq(agree->message.find("self-contained"), std::string::npos)); !failed) failed << agree->message; auto disagree = check_crt_word(word, /*staticCrt=*/true, "dialect_cxxflags"); - ASSERT_TRUE(disagree.has_value()) << word; - EXPECT_TRUE(disagree->contradicts) << word; + expect(fatal(disagree.has_value())) << word; + expect(bool(disagree->contradicts)) << word; } // A debug CRT word is refused under either model: the model has no debug // axis, and the std module and the link use the release CRT. @@ -276,12 +275,12 @@ TEST(CheckCrtWord, AgreeingWordIsRedundantContradictingWordIsRefused) { "-fms-runtime-lib=static_dbg", "-fms-runtime-lib=dll_dbg"}) { for (bool staticCrt : {true, false}) { auto v = check_crt_word(word, staticCrt, "[build] cxxflags"); - ASSERT_TRUE(v.has_value()) << word; - EXPECT_TRUE(v->contradicts) << word; - EXPECT_NE(v->message.find("debug CRT"), std::string::npos) << v->message; + expect(fatal(v.has_value())) << word; + expect(bool(v->contradicts)) << word; + if (auto&& failed = expect(ne(v->message.find("debug CRT"), std::string::npos)); !failed) failed << v->message; } } // A word this axis does not recognise says nothing about it. - EXPECT_FALSE(check_crt_word("-O2", true, "[build] cxxflags").has_value()); - EXPECT_FALSE(check_crt_word("/EHsc", false, "[build] cxxflags").has_value()); -} + expect(!(check_crt_word("-O2", true, "[build] cxxflags").has_value())); + expect(!(check_crt_word("/EHsc", false, "[build] cxxflags").has_value())); +}}; diff --git a/tests/unit/test_toolchain_lifecycle.cpp b/tests/unit/test_toolchain_lifecycle.cpp index 297feb579..d5d58c709 100644 --- a/tests/unit/test_toolchain_lifecycle.cpp +++ b/tests/unit/test_toolchain_lifecycle.cpp @@ -1,6 +1,6 @@ -#include import std; +import mcpplibs.mtest; import mcpp.config; import mcpplibs.os.platform; import mcpp.runtime.binding; @@ -8,6 +8,8 @@ import mcpp.toolchain.lifecycle; import mcpp.toolchain.post_install; import mcpp.toolchain.registry; +using namespace mcpplibs::mtest; + using namespace mcpp::toolchain; // WHAT IS AND IS NOT TESTED HERE, because the difference is not a gap. @@ -31,7 +33,7 @@ using namespace mcpp::toolchain; // takes the ordinary route, and the sweep is precise about what it eats. // Nothing here asserts something this platform cannot express. -TEST(ToolchainRemove, AnOrdinaryPayloadIsJustDeleted) { +UTest an_ordinary_payload_is_just_deleted{"ToolchainRemove/AnOrdinaryPayloadIsJustDeleted", [] { // The common path must not grow a `.trash-` directory: moving files aside // is the fallback, and a fallback that fires always is not a fallback. auto dir = std::filesystem::temp_directory_path() @@ -41,18 +43,18 @@ TEST(ToolchainRemove, AnOrdinaryPayloadIsJustDeleted) { std::ofstream{dir / "14.44.35207" / "bin" / "cl.exe"} << "not a compiler"; std::error_code ec; - EXPECT_TRUE(remove_payload_tree(dir / "14.44.35207", ec)); - EXPECT_FALSE(std::filesystem::exists(dir / "14.44.35207")); + expect(bool(remove_payload_tree(dir / "14.44.35207", ec))); + expect(!std::filesystem::exists(dir / "14.44.35207")); bool anyTrash = false; for (auto& e : std::filesystem::directory_iterator(dir, ec)) anyTrash |= e.path().filename().string().starts_with(".trash-"); - EXPECT_FALSE(anyTrash) << "a deletable payload was parked instead of deleted"; + expect(!anyTrash) << "a deletable payload was parked instead of deleted"; std::filesystem::remove_all(dir, ec); -} +}}; -TEST(ToolchainRemove, TheSweepDoesNotTouchAnotherVersionThatHasNoFilesYet) { +UTest the_sweep_does_not_touch_another_version_that_has_no_files_yet{"ToolchainRemove/TheSweepDoesNotTouchAnotherVersionThatHasNoFilesYet", [] { // An install populates a version directory over time, so a DIFFERENT // version being extracted right now is briefly indistinguishable from a // leftover skeleton. Two mcpp processes against one MCPP_HOME is ordinary @@ -70,16 +72,16 @@ TEST(ToolchainRemove, TheSweepDoesNotTouchAnotherVersionThatHasNoFilesYet) { sweep_parked_payloads(pkgRoot, mine); - EXPECT_FALSE(std::filesystem::exists(mine)) << "the named skeleton survived"; - EXPECT_TRUE(std::filesystem::exists(theirs)) + expect(!std::filesystem::exists(mine)) << "the named skeleton survived"; + expect(std::filesystem::exists(theirs)) << "swept a version this command was not about — that is someone " "else's install being extracted"; std::error_code ec; std::filesystem::remove_all(pkgRoot, ec); -} +}}; -TEST(ToolchainRemove, TheSweepDeletesParkedPayloadsAndNothingElse) { +UTest the_sweep_deletes_parked_payloads_and_nothing_else{"ToolchainRemove/TheSweepDeletesParkedPayloadsAndNothingElse", [] { // The sweep runs before every install and remove, so it must be precise: // `.trash-*` goes, an installed version directory beside it stays. auto pkgRoot = std::filesystem::temp_directory_path() @@ -92,16 +94,16 @@ TEST(ToolchainRemove, TheSweepDeletesParkedPayloadsAndNothingElse) { sweep_parked_payloads(pkgRoot, {}); - EXPECT_FALSE(std::filesystem::exists(pkgRoot / ".trash-14.44.35207-1")) + expect(!std::filesystem::exists(pkgRoot / ".trash-14.44.35207-1")) << "parked payload was not swept"; - EXPECT_TRUE(std::filesystem::exists(pkgRoot / "14.44.35207" / "bin" / "cl.exe")) + expect(std::filesystem::exists(pkgRoot / "14.44.35207" / "bin" / "cl.exe")) << "the sweep deleted an installed toolset"; std::error_code ec; std::filesystem::remove_all(pkgRoot, ec); -} +}}; -TEST(ToolchainRemove, ADirectorySkeletonWithNoFilesCountsAsRemoved) { +UTest a_directory_skeleton_with_no_files_counts_as_removed{"ToolchainRemove/ADirectorySkeletonWithNoFilesCountsAsRemoved", [] { // Windows can refuse to delete an empty DIRECTORY when a process has it // as its current directory — mspdbsrv.exe is launched inside the payload, // so this is the ordinary tail of a /Zi build with the toolset being @@ -116,13 +118,13 @@ TEST(ToolchainRemove, ADirectorySkeletonWithNoFilesCountsAsRemoved) { std::filesystem::create_directories(dir / "14.44.35207" / "bin" / "Hostx64" / "x64"); std::error_code ec; - EXPECT_TRUE(remove_payload_tree(dir / "14.44.35207", ec)) + expect(bool(remove_payload_tree(dir / "14.44.35207", ec))) << "a payload with no files left was reported as still installed"; std::filesystem::remove_all(dir, ec); -} +}}; -TEST(ToolchainRemove, TheSweepAlsoClearsAFileLessSkeleton) { +UTest the_sweep_also_clears_a_file_less_skeleton{"ToolchainRemove/TheSweepAlsoClearsAFileLessSkeleton", [] { // The deferral half: whatever the removal could not delete goes on the // next lifecycle command, once the process holding it has exited. auto pkgRoot = std::filesystem::temp_directory_path() @@ -134,14 +136,14 @@ TEST(ToolchainRemove, TheSweepAlsoClearsAFileLessSkeleton) { sweep_parked_payloads(pkgRoot, pkgRoot / "14.44.35207"); - EXPECT_FALSE(std::filesystem::exists(pkgRoot / "14.44.35207")) + expect(!std::filesystem::exists(pkgRoot / "14.44.35207")) << "the empty skeleton survived the sweep"; - EXPECT_TRUE(std::filesystem::exists(pkgRoot / "14.52.36629" / "bin" / "cl.exe")) + expect(std::filesystem::exists(pkgRoot / "14.52.36629" / "bin" / "cl.exe")) << "the sweep ate an installed toolset"; std::error_code ec; std::filesystem::remove_all(pkgRoot, ec); -} +}}; // ─── The declared C runtime payload (mcpp#660) ───────────────────────────── // @@ -162,53 +164,53 @@ mcpp::config::GlobalConfig unreachable_cfg() { } // namespace -TEST(DeclaredRuntime, ABindingThatLocatesItsPayloadIsLeftAlone) { +UTest a_binding_that_locates_its_payload_is_left_alone{"DeclaredRuntime/ABindingThatLocatesItsPayloadIsLeftAlone", [] { auto cfg = unreachable_cfg(); mcpp::runtime::RuntimeBinding binding; binding.runtimeId = "glibc@2.44.3"; binding.libraryDirs = {"/store/xim-x-glibc/2.44.3/lib64"}; const auto before = binding.libraryDirs; - EXPECT_EQ(ensure_declared_runtime(cfg, binding).value_or(true), false); - EXPECT_EQ(binding.libraryDirs, before); - EXPECT_EQ(binding.runtimeId, "glibc@2.44.3"); -} + expect(eq(ensure_declared_runtime(cfg, binding).value_or(true), false)); + expect(eq(binding.libraryDirs, before)); + expect(eq(binding.runtimeId, "glibc@2.44.3")); +}}; // A provider that binds no payload (`ucrt` is an OS component) and an absent // identity are not requests to install anything. -TEST(DeclaredRuntime, OnlyAGlibcPayloadIsInstalled) { +UTest only_a_glibc_payload_is_installed{"DeclaredRuntime/OnlyAGlibcPayloadIsInstalled", [] { auto cfg = unreachable_cfg(); for (auto id : {"", "ucrt@10.0.26100.0", "macos_sdk@15.0", "glibc"}) { mcpp::runtime::RuntimeBinding binding; binding.runtimeId = id; - EXPECT_EQ(ensure_declared_runtime(cfg, binding).value_or(true), false) << id; - EXPECT_TRUE(binding.libraryDirs.empty()) << id; + expect(eq(ensure_declared_runtime(cfg, binding).value_or(true), false)) << id; + expect(binding.libraryDirs.empty()) << id; } -} +}}; // Off Linux there is no glibc payload to provide, whatever the identity says. -TEST(DeclaredRuntime, NothingIsInstalledOffLinux) { +UTest nothing_is_installed_off_linux{"DeclaredRuntime/NothingIsInstalledOffLinux", [] { if constexpr (mcpplibs::os::is_linux) - GTEST_SKIP() << "the Linux path is exercised by e2e 700"; + skip("the Linux path is exercised by e2e 700"); auto cfg = unreachable_cfg(); mcpp::runtime::RuntimeBinding binding; binding.runtimeId = "glibc@2.44.3"; - EXPECT_EQ(ensure_declared_runtime(cfg, binding).value_or(true), false); -} + expect(eq(ensure_declared_runtime(cfg, binding).value_or(true), false)); +}}; // Which toolchains run on a C runtime payload: a GNU gcc and an llvm on // Linux. Any other toolchain must not cause a runtime download. -TEST(DeclaredRuntime, OnlyToolchainsOnARuntimePayloadNeedIt) { +UTest only_toolchains_on_a_runtime_payload_need_it{"DeclaredRuntime/OnlyToolchainsOnARuntimePayloadNeedIt", [] { XimToolchainPackage gcc; gcc.ximName = "gcc"; gcc.needsGccPostInstallFixup = true; XimToolchainPackage llvm; llvm.ximName = "llvm"; XimToolchainPackage musl; musl.ximName = "musl-gcc"; XimToolchainPackage msvc; msvc.ximName = "msvc"; if constexpr (mcpplibs::os::is_windows) { for (auto const* p : {&gcc, &llvm, &musl, &msvc}) - EXPECT_TRUE(runtime_payload_kind(*p).empty()) << p->ximName; + expect(runtime_payload_kind(*p).empty()) << p->ximName; } else { - EXPECT_EQ(runtime_payload_kind(gcc), "gcc"); - EXPECT_EQ(runtime_payload_kind(llvm), "llvm"); - EXPECT_TRUE(runtime_payload_kind(musl).empty()); - EXPECT_TRUE(runtime_payload_kind(msvc).empty()); + expect(eq(runtime_payload_kind(gcc), "gcc")); + expect(eq(runtime_payload_kind(llvm), "llvm")); + expect(runtime_payload_kind(musl).empty()); + expect(runtime_payload_kind(msvc).empty()); } -} +}}; diff --git a/tests/unit/test_toolchain_msvc.cpp b/tests/unit/test_toolchain_msvc.cpp index 0f2953d94..2720bae9d 100644 --- a/tests/unit/test_toolchain_msvc.cpp +++ b/tests/unit/test_toolchain_msvc.cpp @@ -1,59 +1,61 @@ -#include import std; +import mcpplibs.mtest; import mcpp.invocation; import mcpp.toolchain.model; import mcpp.toolchain.msvc; import mcpp.toolchain.registry; import mcpp.toolchain.dialect; +using namespace mcpplibs::mtest; + using namespace mcpp::toolchain; // ─── parse_cl_banner ───────────────────────────────────────────────────── -TEST(MsvcBanner, ParsesEnglishBanner) { +UTest parses_english_banner{"MsvcBanner/ParsesEnglishBanner", [] { auto r = msvc::parse_cl_banner( "Microsoft (R) C/C++ Optimizing Compiler Version 19.44.35211 for x64\n" "Copyright (C) Microsoft Corporation. All rights reserved.\n" "\n" "usage: cl [ option... ] filename... [ /link linkoption... ]\n"); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(r->first, "19.44.35211"); - EXPECT_EQ(r->second, "x64"); -} + expect(fatal(r.has_value())); + expect(eq(r->first, "19.44.35211")); + expect(eq(r->second, "x64")); +}}; -TEST(MsvcBanner, ParsesLocalizedBannerByTokens) { +UTest parses_localized_banner_by_tokens{"MsvcBanner/ParsesLocalizedBannerByTokens", [] { // Chinese VS reorders the sentence; only the tokens are stable. auto r = msvc::parse_cl_banner( "用于 x64 的 Microsoft (R) C/C++ 优化编译器 19.44.35211 版\n" "版权所有(C) Microsoft Corporation。保留所有权利。\n"); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(r->first, "19.44.35211"); - EXPECT_EQ(r->second, "x64"); -} + expect(fatal(r.has_value())); + expect(eq(r->first, "19.44.35211")); + expect(eq(r->second, "x64")); +}}; -TEST(MsvcBanner, ParsesArm64AndFourComponentVersions) { +UTest parses_arm64_and_four_component_versions{"MsvcBanner/ParsesArm64AndFourComponentVersions", [] { auto r = msvc::parse_cl_banner( "Microsoft (R) C/C++ Optimizing Compiler Version 19.29.30133.0 for ARM64\n"); - ASSERT_TRUE(r.has_value()); - EXPECT_EQ(r->first, "19.29.30133.0"); - EXPECT_EQ(r->second, "arm64"); -} - -TEST(MsvcBanner, RejectsGarbage) { - EXPECT_FALSE(msvc::parse_cl_banner("").has_value()); - EXPECT_FALSE(msvc::parse_cl_banner("bash: cl: command not found").has_value()); + expect(fatal(r.has_value())); + expect(eq(r->first, "19.29.30133.0")); + expect(eq(r->second, "arm64")); +}}; + +UTest rejects_garbage{"MsvcBanner/RejectsGarbage", [] { + expect(!(msvc::parse_cl_banner("").has_value())); + expect(!(msvc::parse_cl_banner("bash: cl: command not found").has_value())); // A bare two-component number is not a cl version. - EXPECT_FALSE(msvc::parse_cl_banner("something 1.2 else").has_value()); -} + expect(!(msvc::parse_cl_banner("something 1.2 else").has_value())); +}}; -TEST(MsvcBanner, TripleForArch) { - EXPECT_EQ(msvc::triple_for_arch("x64"), "x86_64-pc-windows-msvc"); - EXPECT_EQ(msvc::triple_for_arch("x86"), "i686-pc-windows-msvc"); - EXPECT_EQ(msvc::triple_for_arch("arm64"), "aarch64-pc-windows-msvc"); +UTest msvc_banner_triple_for_arch{"MsvcBanner/TripleForArch", [] { + expect(eq(msvc::triple_for_arch("x64"), "x86_64-pc-windows-msvc")); + expect(eq(msvc::triple_for_arch("x86"), "i686-pc-windows-msvc")); + expect(eq(msvc::triple_for_arch("arm64"), "aarch64-pc-windows-msvc")); // Unknown/empty arch falls back to the x64 triple. - EXPECT_EQ(msvc::triple_for_arch(""), "x86_64-pc-windows-msvc"); -} + expect(eq(msvc::triple_for_arch(""), "x86_64-pc-windows-msvc")); +}}; // ─── install guidance ──────────────────────────────────────────────────── @@ -75,26 +77,26 @@ std::string fixed_file_info(std::uint32_t ms, std::uint32_t ls, } } // namespace -TEST(MsvcCompilerVersion, ReadsTheFixedFileInfoAsClangDoes) { +UTest reads_the_fixed_file_info_as_clang_does{"MsvcCompilerVersion/ReadsTheFixedFileInfoAsClangDoes", [] { // 19.51.36257.0: MS = 19 << 16 | 51, LS = 36257 << 16 | 0. std::string image(300, 'x'); image += fixed_file_info((19u << 16) | 51u, (36257u << 16) | 0u); image += std::string(40, 'y'); auto v = mcpp::toolchain::msvc::pe_file_version(image); - ASSERT_TRUE(v.has_value()); - EXPECT_EQ(*v, "19.51.36257"); -} + expect(fatal(v.has_value())); + expect(eq(*v, "19.51.36257")); +}}; -TEST(MsvcCompilerVersion, SkipsASignatureThatIsNotTheStructure) { +UTest skips_a_signature_that_is_not_the_structure{"MsvcCompilerVersion/SkipsASignatureThatIsNotTheStructure", [] { // The four signature bytes can occur in code; only the structure, whose // next word is 0x00010000, is read. std::string image = fixed_file_info((1u << 16) | 2u, (3u << 16), 0xDEADBEEFu); image += fixed_file_info((19u << 16) | 44u, (35211u << 16) | 7u); - EXPECT_EQ(mcpp::toolchain::msvc::pe_file_version(image).value_or(""), "19.44.35211"); - EXPECT_FALSE(mcpp::toolchain::msvc::pe_file_version("no version here").has_value()); -} + expect(eq(mcpp::toolchain::msvc::pe_file_version(image).value_or(""), "19.44.35211")); + expect(!(mcpp::toolchain::msvc::pe_file_version("no version here").has_value())); +}}; -TEST(MsvcCompilerVersion, IsReadFromTheCompilerClangReadsForTheTarget) { +UTest is_read_from_the_compiler_clang_reads_for_the_target{"MsvcCompilerVersion/IsReadFromTheCompilerClangReadsForTheTarget", [] { // clang reads bin/Host{x64|x86}//cl.exe; each target's compiler // here carries its own build, under both host directories, so the answer // depends on the target and not on which host runs the test. @@ -111,94 +113,94 @@ TEST(MsvcCompilerVersion, IsReadFromTheCompilerClangReadsForTheTarget) { os << std::string(64, 'M') << fixed_file_info((19u << 16) | 51u, (build << 16)); } namespace msvc = mcpp::toolchain::msvc; - EXPECT_EQ(msvc::compiler_version_in_tools_dir(tools, "x86_64"), "19.51.36260"); - EXPECT_EQ(msvc::compiler_version_in_tools_dir(tools, "aarch64"), "19.51.36261"); - EXPECT_EQ(msvc::compiler_version_in_tools_dir(tools, "i686"), "19.51.36262"); + expect(eq(msvc::compiler_version_in_tools_dir(tools, "x86_64"), "19.51.36260")); + expect(eq(msvc::compiler_version_in_tools_dir(tools, "aarch64"), "19.51.36261")); + expect(eq(msvc::compiler_version_in_tools_dir(tools, "i686"), "19.51.36262")); // Every 32-bit x86 spelling is the x86 directory (2026.10.5.3; i386-i586 // used to read x64's compiler). - EXPECT_EQ(msvc::compiler_version_in_tools_dir(tools, "i386"), "19.51.36262"); - EXPECT_EQ(msvc::compiler_version_in_tools_dir(tools, "i586"), "19.51.36262"); + expect(eq(msvc::compiler_version_in_tools_dir(tools, "i386"), "19.51.36262")); + expect(eq(msvc::compiler_version_in_tools_dir(tools, "i586"), "19.51.36262")); // No compiler where clang looks: no version, and clang falls back itself. - EXPECT_EQ(msvc::compiler_version_in_tools_dir(tools / "absent", "x86_64"), ""); + expect(eq(msvc::compiler_version_in_tools_dir(tools / "absent", "x86_64"), "")); std::error_code ec; fs::remove_all(tools.parent_path().parent_path().parent_path().parent_path(), ec); -} +}}; -TEST(MsvcGuidance, OffersBothOrigins) { +UTest offers_both_origins{"MsvcGuidance/OffersBothOrigins", [] { auto g = msvc::install_guidance(); - ASSERT_FALSE(g.empty()); + expect(fatal(!g.empty())); // The managed origin has to be reachable from the message a user sees // when nothing is installed — otherwise "mcpp can install a toolset" is // true and undiscoverable at the same time. - EXPECT_NE(g.find("mcpp toolchain install msvc"), std::string::npos) << g; - EXPECT_NE(g.find("[toolchain]"), std::string::npos) << g; + expect(ne(g.find("mcpp toolchain install msvc"), std::string::npos)) << g; + expect(ne(g.find("[toolchain]"), std::string::npos)) << g; // …and the system origin stays offered, with its own route. - EXPECT_NE(g.find("winget"), std::string::npos) << g; - EXPECT_NE(g.find("msvc@system"), std::string::npos) << g; - EXPECT_NE(g.find("mcpp toolchain default msvc"), std::string::npos) << g; -} + expect(ne(g.find("winget"), std::string::npos)) << g; + expect(ne(g.find("msvc@system"), std::string::npos)) << g; + expect(ne(g.find("mcpp toolchain default msvc"), std::string::npos)) << g; +}}; // ─── spec layer: the VERSION axis decides the origin ───────────────────── -TEST(MsvcSpec, SystemOriginIsTheUnversionedSpec) { +UTest system_origin_is_the_unversioned_spec{"MsvcSpec/SystemOriginIsTheUnversionedSpec", [] { for (auto s : {"msvc", "msvc@system"}) { auto spec = parse_toolchain_spec(s); - ASSERT_TRUE(spec.has_value()) << s; - EXPECT_TRUE(is_system_toolchain(*spec)) << s; + expect(fatal(spec.has_value())) << s; + expect(is_system_toolchain(*spec)) << s; } auto gcc = parse_toolchain_spec("gcc@16.1.0"); - ASSERT_TRUE(gcc.has_value()); - EXPECT_FALSE(is_system_toolchain(*gcc)); -} + expect(fatal(gcc.has_value())); + expect(!is_system_toolchain(*gcc)); +}}; -TEST(MsvcSpec, ToolsetVersionIsAManagedPayloadNotASystemSpec) { +UTest toolset_version_is_a_managed_payload_not_a_system_spec{"MsvcSpec/ToolsetVersionIsAManagedPayloadNotASystemSpec", [] { // The defect this closes: EVERY msvc spec used to be a system spec, so a // manifest could name a toolset and silently get whatever the machine // had. A version means a payload, exactly like gcc@16.1.0. for (auto s : {"msvc@14.44.35207", "msvc@14.52.36629"}) { auto spec = parse_toolchain_spec(s); - ASSERT_TRUE(spec.has_value()) << s; - EXPECT_FALSE(is_system_toolchain(*spec)) << s; + expect(fatal(spec.has_value())) << s; + expect(!is_system_toolchain(*spec)) << s; auto pkg = to_xim_package(*spec); - EXPECT_EQ(pkg.ximName, "msvc") << s; - EXPECT_EQ(pkg.ximVersion, std::string(s).substr(5)) << s; + expect(eq(pkg.ximName, "msvc")) << s; + expect(eq(pkg.ximVersion, std::string(s).substr(5))) << s; } -} +}}; -TEST(MsvcSpec, StableDefaultMatchesAnyDetectedVersion) { +UTest stable_default_matches_any_detected_version{"MsvcSpec/StableDefaultMatchesAnyDetectedVersion", [] { // The persisted default is always the stable "msvc@system" — it matches // whatever concrete version detection reports (family-level match). auto def = parse_toolchain_spec("msvc@system"); - ASSERT_TRUE(def.has_value()); + expect(fatal(def.has_value())); PayloadIdentity msvcId{ Family::Msvc, {} }; - EXPECT_TRUE(spec_matches_payload(*def, msvcId, "19.44.35211")); - EXPECT_TRUE(spec_matches_payload(*def, msvcId, "19.29.30133")); + expect(bool(spec_matches_payload(*def, msvcId, "19.44.35211"))); + expect(bool(spec_matches_payload(*def, msvcId, "19.29.30133"))); PayloadIdentity gccId{ Family::Gcc, {} }; - EXPECT_FALSE(spec_matches_payload(*def, gccId, "16.1.0")); + expect(!spec_matches_payload(*def, gccId, "16.1.0")); auto gccDef = parse_toolchain_spec("gcc@16.1.0"); - ASSERT_TRUE(gccDef.has_value()); - EXPECT_FALSE(spec_matches_payload(*gccDef, msvcId, "19.44.35211")); -} + expect(fatal(gccDef.has_value())); + expect(!spec_matches_payload(*gccDef, msvcId, "19.44.35211")); +}}; -TEST(MsvcSpec, PinnedToolsetMatchesOnlyItsOwnPayload) { +UTest pinned_toolset_matches_only_its_own_payload{"MsvcSpec/PinnedToolsetMatchesOnlyItsOwnPayload", [] { // The other half of the family-level match above: once a version is // named, it has to be compared. A pin that matched any payload would be // the same silent divergence wearing a version number. auto pinned = parse_toolchain_spec("msvc@14.52.36629"); - ASSERT_TRUE(pinned.has_value()); + expect(fatal(pinned.has_value())); PayloadIdentity msvcId{ Family::Msvc, {} }; - EXPECT_TRUE(spec_matches_payload(*pinned, msvcId, "14.52.36629")); - EXPECT_FALSE(spec_matches_payload(*pinned, msvcId, "14.44.35207")); -} + expect(bool(spec_matches_payload(*pinned, msvcId, "14.52.36629"))); + expect(!spec_matches_payload(*pinned, msvcId, "14.44.35207")); +}}; -TEST(MsvcSpec, PayloadDirectoryIsIdentifiedAsMsvc) { +UTest payload_directory_is_identified_as_msvc{"MsvcSpec/PayloadDirectoryIsIdentifiedAsMsvc", [] { // Installed payloads are listed by walking `xim-x-` directories. // Without this row an install would succeed and then be invisible. auto id = identify_xim_payload("msvc"); - ASSERT_TRUE(id.has_value()); - EXPECT_EQ(id->family, Family::Msvc); - EXPECT_TRUE(id->target.empty()); // host-target, like gcc and llvm -} + expect(fatal(id.has_value())); + expect(eq(id->family, Family::Msvc)); + expect(id->target.empty()); // host-target, like gcc and llvm +}}; // ─── managed origin: resolution from a payload ─────────────────────────── // @@ -274,7 +276,7 @@ struct NoSdkEnv { } // namespace -TEST(MsvcManaged, ResolvesTheDeclaredToolsetAndNotItsNeighbour) { +UTest resolves_the_declared_toolset_and_not_its_neighbour{"MsvcManaged/ResolvesTheDeclaredToolsetAndNotItsNeighbour", [] { FakeToolset t{"pick"}; t.add_toolset("14.44.35207"); t.add_toolset("14.52.36629"); @@ -283,18 +285,18 @@ TEST(MsvcManaged, ResolvesTheDeclaredToolsetAndNotItsNeighbour) { // answer 14.52 — the exact substitution the managed origin exists to // prevent. auto older = msvc::installation_at(t.root, "14.44.35207"); - ASSERT_TRUE(older.has_value()); - EXPECT_EQ(older->toolsVersion, "14.44.35207"); - EXPECT_TRUE(older->hasStdModules); - EXPECT_EQ(older->clPath.filename(), "cl.exe"); - EXPECT_NE(older->clPath.string().find("14.44.35207"), std::string::npos); + expect(fatal(older.has_value())); + expect(eq(older->toolsVersion, "14.44.35207")); + expect(bool(older->hasStdModules)); + expect(eq(older->clPath.filename(), "cl.exe")); + expect(ne(older->clPath.string().find("14.44.35207"), std::string::npos)); auto newer = msvc::installation_at(t.root, "14.52.36629"); - ASSERT_TRUE(newer.has_value()); - EXPECT_EQ(newer->toolsVersion, "14.52.36629"); -} + expect(fatal(newer.has_value())); + expect(eq(newer->toolsVersion, "14.52.36629")); +}}; -TEST(MsvcManaged, TheVersionDirIsTheRootNotItsLoneSubdirectory) { +UTest the_version_dir_is_the_root_not_its_lone_subdirectory{"MsvcManaged/TheVersionDirIsTheRootNotItsLoneSubdirectory", [] { // A real installed payload has exactly ONE entry: `VC/`. The fetcher's // `XpkgPayload::root` treats the version directory as the root only when // it directly contains bin/ include/ lib/, and otherwise descends into a @@ -309,35 +311,35 @@ TEST(MsvcManaged, TheVersionDirIsTheRootNotItsLoneSubdirectory) { t.add_toolset("14.44.35207"); auto ok = msvc::installation_at(t.root, "14.44.35207"); - ASSERT_TRUE(ok.has_value()) << "the version directory must be the root"; + expect(fatal(ok.has_value())) << "the version directory must be the root"; - EXPECT_FALSE(msvc::installation_at(t.root / "VC", "14.44.35207").has_value()) + expect(!(msvc::installation_at(t.root / "VC", "14.44.35207").has_value())) << "a caller handing in the VC subdir is passing the wrong thing, and " "must be told so rather than quietly rescued"; -} +}}; -TEST(MsvcManaged, AbsentToolsetIsNulloptNotASubstitute) { +UTest absent_toolset_is_nullopt_not_a_substitute{"MsvcManaged/AbsentToolsetIsNulloptNotASubstitute", [] { FakeToolset t{"absent"}; t.add_toolset("14.44.35207"); // Nothing is "close enough": a pin that silently fell back would report // success while building with a compiler nobody asked for. - EXPECT_FALSE(msvc::installation_at(t.root, "14.52.36629").has_value()); - EXPECT_FALSE(msvc::installation_at(t.root, "14.44").has_value()); - EXPECT_FALSE(msvc::installation_at(t.root, "").has_value()); -} + expect(!(msvc::installation_at(t.root, "14.52.36629").has_value())); + expect(!(msvc::installation_at(t.root, "14.44").has_value())); + expect(!(msvc::installation_at(t.root, "").has_value())); +}}; -TEST(MsvcManaged, VersionFallsBackToTheDeclaredOneWhenTheBannerCannotBeRead) { +UTest version_falls_back_to_the_declared_one_when_the_banner_cannot_be_read{"MsvcManaged/VersionFallsBackToTheDeclaredOneWhenTheBannerCannotBeRead", [] { // A fixture cl.exe produces no banner (and on Windows a real one would). // display_version() must still name the toolset rather than an empty // string — the declared version is a fact even when the probe fails. FakeToolset t{"banner"}; t.add_toolset("14.52.36629"); auto inst = msvc::installation_at(t.root, "14.52.36629"); - ASSERT_TRUE(inst.has_value()); - EXPECT_EQ(inst->display_version(), "14.52.36629"); -} + expect(fatal(inst.has_value())); + expect(eq(inst->display_version(), "14.52.36629")); +}}; -TEST(MsvcManaged, PayloadFrontendFindsClWhereMsvcActuallyKeepsIt) { +UTest payload_frontend_finds_cl_where_msvc_actually_keeps_it{"MsvcManaged/PayloadFrontendFindsClWhereMsvcActuallyKeepsIt", [] { // The defect this pins: `toolchain list` asked `toolchain_frontend(root / // "bin", …)`, got nothing, and skipped the row -- so an msvc toolset // installed correctly and then did not appear anywhere. Three places need @@ -346,28 +348,28 @@ TEST(MsvcManaged, PayloadFrontendFindsClWhereMsvcActuallyKeepsIt) { t.add_toolset("14.44.35207"); auto spec = parse_toolchain_spec("msvc@14.44.35207"); - ASSERT_TRUE(spec.has_value()); + expect(fatal(spec.has_value())); auto pkg = to_xim_package(*spec); // The family comes from the PACKAGE now, not from a second argument every // caller had to source from a differently-named local -- five of them // composed `payload->binDir` themselves instead and so could not see // `frontendSubdir` at all. - EXPECT_EQ(pkg.family, Family::Msvc); + expect(eq(pkg.family, Family::Msvc)); auto found = payload_frontend(t.root, pkg); - ASSERT_TRUE(found.has_value()) << found.error(); - ASSERT_FALSE(found->empty()) + if (auto&& failed = expect(fatal(found.has_value())); !failed) failed << found.error(); + expect(fatal(!found->empty())) << "payload_frontend found no cl.exe under " << t.root; - EXPECT_EQ(found->filename(), "cl.exe"); + expect(eq(found->filename(), "cl.exe")); // The `bin/`-shaped question is the one that used to be asked, and it // still answers nothing here — which is exactly why it was the wrong // question rather than a broken implementation. - EXPECT_TRUE(toolchain_frontend(t.root / "bin", pkg).empty()); + expect(toolchain_frontend(t.root / "bin", pkg).empty()); // A root with no toolset at that version stays empty rather than // returning a path that does not exist. - EXPECT_TRUE(payload_frontend(t.root, + expect(payload_frontend(t.root, to_xim_package(*parse_toolchain_spec("msvc@14.52.36629"))) .value().empty()); @@ -376,24 +378,24 @@ TEST(MsvcManaged, PayloadFrontendFindsClWhereMsvcActuallyKeepsIt) { // carries the toolset version, so a refusal naming `bin` would name a // directory nothing looked in. auto dir = payload_frontend_dir(t.root, pkg); - EXPECT_FALSE(dir.empty()); - EXPECT_NE(dir.string().find("14.44.35207"), std::string::npos) << dir; -} + expect(!dir.empty()); + expect(ne(dir.string().find("14.44.35207"), std::string::npos)) << dir; +}}; // ─── Windows SDK discovery ─────────────────────────────────────────────── -TEST(MsvcSdk, WindowsSdkDirBeatsTheHardcodedPaths) { +UTest windows_sdk_dir_beats_the_hardcoded_paths{"MsvcSdk/WindowsSdkDirBeatsTheHardcodedPaths", [] { NoSdkEnv clean; FakeToolset t{"sdkenv"}; t.add_sdk("10.0.26100.0"); ScopedEnv dir{"WindowsSdkDir", t.root.string()}; auto sdk = msvc::find_windows_sdk(); - ASSERT_TRUE(sdk.has_value()); - EXPECT_EQ(sdk->root, t.root); - EXPECT_EQ(sdk->version, "10.0.26100.0"); -} + expect(fatal(sdk.has_value())); + expect(eq(sdk->root, t.root)); + expect(eq(sdk->version, "10.0.26100.0")); +}}; -TEST(MsvcSdk, WindowsSdkVersionSelectsAmongInstalledOnes) { +UTest windows_sdk_version_selects_among_installed_ones{"MsvcSdk/WindowsSdkVersionSelectsAmongInstalledOnes", [] { NoSdkEnv clean; FakeToolset t{"sdkver"}; t.add_sdk("10.0.22621.0"); @@ -403,16 +405,16 @@ TEST(MsvcSdk, WindowsSdkVersionSelectsAmongInstalledOnes) { // the directory name, and comparing it raw finds nothing. ScopedEnv ver{"WindowsSdkVersion", "10.0.22621.0\\"}; auto sdk = msvc::find_windows_sdk(); - ASSERT_TRUE(sdk.has_value()); - EXPECT_EQ(sdk->version, "10.0.22621.0"); + expect(fatal(sdk.has_value())); + expect(eq(sdk->version, "10.0.22621.0")); } // Unset again: highest usable wins. auto sdk = msvc::find_windows_sdk(); - ASSERT_TRUE(sdk.has_value()); - EXPECT_EQ(sdk->version, "10.0.26100.0"); -} + expect(fatal(sdk.has_value())); + expect(eq(sdk->version, "10.0.26100.0")); +}}; -TEST(MsvcSdk, ExtraRootsCoverTheManagedPayload) { +UTest extra_roots_cover_the_managed_payload{"MsvcSdk/ExtraRootsCoverTheManagedPayload", [] { NoSdkEnv clean; FakeToolset t{"sdkextra"}; t.add_sdk("10.0.26100.0"); @@ -420,11 +422,11 @@ TEST(MsvcSdk, ExtraRootsCoverTheManagedPayload) { // found, which is exactly the managed toolset's situation. std::array roots{t.root}; auto sdk = msvc::find_windows_sdk(roots); - ASSERT_TRUE(sdk.has_value()); - EXPECT_EQ(sdk->root, t.root); -} + expect(fatal(sdk.has_value())); + expect(eq(sdk->root, t.root)); +}}; -TEST(MsvcSdk, IncompleteSdkRootIsNotAnAnswer) { +UTest incomplete_sdk_root_is_not_an_answer{"MsvcSdk/IncompleteSdkRootIsNotAnAnswer", [] { // Include// exists but carries no ucrt/corecrt.h: a half-installed // SDK must read as absent, not as a usable one that fails later inside // the compiler. Checked through extraRoots so the assertion cannot be @@ -434,8 +436,8 @@ TEST(MsvcSdk, IncompleteSdkRootIsNotAnAnswer) { std::filesystem::create_directories(t.root / "Include" / "10.0.26100.0" / "um"); std::array roots{t.root}; auto sdk = msvc::find_windows_sdk(roots); - if (sdk) EXPECT_NE(sdk->root, t.root) << "an SDK-less root was accepted"; -} + if (sdk) expect(ne(sdk->root, t.root)) << "an SDK-less root was accepted"; +}}; // ─── the toolset's own redistributable CRT ─────────────────────────────── @@ -477,89 +479,88 @@ struct FakeRedist { } // namespace -TEST(MsvcRedist, FoundFromTheCompilerPathAlone) { +UTest found_from_the_compiler_path_alone{"MsvcRedist/FoundFromTheCompilerPathAlone", [] { // Nothing configured, no version derived: the compiler's own location // says which toolset this is, and the toolset carries its runtime. FakeRedist t{"14.44.35207", "14.44.35112"}; auto d = msvc::vc_redist_dir(t.clPath, "x64"); - ASSERT_FALSE(d.empty()) << "the toolset's redistributable CRT was not found"; - EXPECT_TRUE(std::filesystem::exists(d / "vcruntime140.dll")); -} + expect(fatal(!d.empty())) << "the toolset's redistributable CRT was not found"; + expect(std::filesystem::exists(d / "vcruntime140.dll")); +}}; -TEST(MsvcRedist, TheRedistVersionIsNotTheToolsVersion) { +UTest the_redist_version_is_not_the_tools_version{"MsvcRedist/TheRedistVersionIsNotTheToolsVersion", [] { // 14.44.35207 (tools) vs 14.44.35112 (redist) is what MSVC actually // ships. Deriving one from the other finds nothing — this is why the // directory is searched rather than composed. FakeRedist t{"14.44.35207", "14.44.35112"}; auto d = msvc::vc_redist_dir(t.clPath, "x64"); - ASSERT_FALSE(d.empty()); - EXPECT_NE(d.string().find("14.44.35112"), std::string::npos) + expect(fatal(!d.empty())); + expect(ne(d.string().find("14.44.35112"), std::string::npos)) << "did not land in the redist version directory: " << d.string(); -} +}}; -TEST(MsvcRedist, NewestRedistWins) { +UTest newest_redist_wins{"MsvcRedist/NewestRedistWins", [] { FakeRedist t{"14.44.35207", "14.40.00000"}; t.add("14.44.35112", "x64", "Microsoft.VC143.CRT", "vcruntime140.dll"); auto d = msvc::vc_redist_dir(t.clPath, "x64"); - ASSERT_FALSE(d.empty()); - EXPECT_NE(d.string().find("14.44.35112"), std::string::npos) + expect(fatal(!d.empty())); + expect(ne(d.string().find("14.44.35112"), std::string::npos)) << "older redist won: " << d.string(); -} +}}; -TEST(MsvcRedist, TheDebugCrtIsNeverReturned) { +UTest the_debug_crt_is_never_returned{"MsvcRedist/TheDebugCrtIsNeverReturned", [] { // debug_nonredist may NOT be redistributed. Returning it would put // vcruntime140d.dll on PATH and, later, into a shipped artifact. FakeRedist t{"14.44.35207", "14.44.35112"}; t.add_debug("14.99.99999"); // newer, and must still lose auto d = msvc::vc_redist_dir(t.clPath, "x64"); - ASSERT_FALSE(d.empty()); - EXPECT_EQ(d.string().find("debug_nonredist"), std::string::npos) + expect(fatal(!d.empty())); + expect(eq(d.string().find("debug_nonredist"), std::string::npos)) << "returned the non-redistributable debug CRT: " << d.string(); -} +}}; -TEST(MsvcRedist, AToolsetWithoutARedistIsNotAnError) { +UTest a_toolset_without_a_redist_is_not_an_error{"MsvcRedist/AToolsetWithoutARedistIsNotAnError", [] { // msvc@system on a machine whose VS install omits the redist component, // for instance. Empty means "nothing to add to PATH", not a failure. FakeRedist t{"14.44.35207", "14.44.35112"}; std::error_code ec; std::filesystem::remove_all(t.root / "VC" / "Redist", ec); - EXPECT_TRUE(msvc::vc_redist_dir(t.clPath, "x64").empty()); -} + expect(msvc::vc_redist_dir(t.clPath, "x64").empty()); +}}; // #718: the LLVM row reaches the SAME redistributable from its sysroot's // tools directory, since it runs no cl.exe of its own to derive one from. -TEST(MsvcRedist, TheSameDirectoryIsReachableFromTheToolsDirAlone) { +UTest the_same_directory_is_reachable_from_the_tools_dir_alone{"MsvcRedist/TheSameDirectoryIsReachableFromTheToolsDirAlone", [] { FakeRedist t{"14.44.35207", "14.44.35112"}; // /Tools/MSVC//bin/Hostx64/x64/cl.exe -> up 4 reaches the tools // dir `Toolchain::msvcToolsDir` carries (/Tools/MSVC/). auto toolsDir = t.clPath.parent_path().parent_path().parent_path().parent_path(); auto fromCl = msvc::vc_redist_dir(t.clPath, "x64"); auto fromTools = msvc::vc_redist_dir_for_tools_dir(toolsDir, "x86_64"); - ASSERT_FALSE(fromCl.empty()); - EXPECT_EQ(fromCl, fromTools); -} + expect(fatal(!fromCl.empty())); + expect(eq(fromCl, fromTools)); +}}; -TEST(MsvcRedist, ToolsDirArchMapping) { +UTest tools_dir_arch_mapping{"MsvcRedist/ToolsDirArchMapping", [] { // The GNU-spelled triple architecture (what a Toolchain carries) maps to // the msvc spelling `vc_redist_dir` takes. FakeRedist t{"14.44.35207", "14.44.35112"}; t.add("14.44.35112", "arm64", "Microsoft.VC143.CRT", "vcruntime140.dll"); t.add("14.44.35112", "x86", "Microsoft.VC143.CRT", "vcruntime140.dll"); auto toolsDir = t.clPath.parent_path().parent_path().parent_path().parent_path(); - EXPECT_FALSE(msvc::vc_redist_dir_for_tools_dir(toolsDir, "aarch64").empty()); - EXPECT_FALSE(msvc::vc_redist_dir_for_tools_dir(toolsDir, "i686").empty()); - EXPECT_EQ(msvc::vc_redist_dir_for_tools_dir(toolsDir, "i386"), - msvc::vc_redist_dir_for_tools_dir(toolsDir, "i686")); - EXPECT_FALSE(msvc::vc_redist_dir_for_tools_dir(toolsDir, "x86_64").empty()); + expect(!(msvc::vc_redist_dir_for_tools_dir(toolsDir, "aarch64").empty())); + expect(!(msvc::vc_redist_dir_for_tools_dir(toolsDir, "i686").empty())); + expect(eq(msvc::vc_redist_dir_for_tools_dir(toolsDir, "i386"), msvc::vc_redist_dir_for_tools_dir(toolsDir, "i686"))); + expect(!(msvc::vc_redist_dir_for_tools_dir(toolsDir, "x86_64").empty())); // An architecture this toolset was never given a redist for finds none — // it does not fall back to a different one. std::error_code ec; auto vc = toolsDir.parent_path().parent_path().parent_path(); // std::filesystem::remove_all(vc / "Redist" / "MSVC" / "14.44.35112" / "arm64", ec); - EXPECT_TRUE(msvc::vc_redist_dir_for_tools_dir(toolsDir, "aarch64").empty()); -} + expect(msvc::vc_redist_dir_for_tools_dir(toolsDir, "aarch64").empty()); +}}; -TEST(MsvcSdk, HeadersWithoutImportLibsIsNotAnAnswer) { +UTest headers_without_import_libs_is_not_an_answer{"MsvcSdk/HeadersWithoutImportLibsIsNotAnAnswer", [] { // The half that used to pass. `Include//ucrt/corecrt.h` is there and // `Lib/` is not, which is exactly what a managed windows-sdk payload // looked like when its um-libs MSI had not unpacked: every TU compiled @@ -573,11 +574,11 @@ TEST(MsvcSdk, HeadersWithoutImportLibsIsNotAnAnswer) { t.add_sdk_headers_only("10.0.26100.0"); std::array roots{t.root}; auto sdk = msvc::find_windows_sdk(roots); - if (sdk) EXPECT_NE(sdk->root, t.root) + if (sdk) expect(ne(sdk->root, t.root)) << "a headers-only SDK was accepted; the link would fail on kernel32.lib"; -} +}}; -TEST(MsvcSdk, ACompleteRootIsStillAccepted) { +UTest a_complete_root_is_still_accepted{"MsvcSdk/ACompleteRootIsStillAccepted", [] { // The other direction, so the check above cannot be satisfied by // rejecting everything. NoSdkEnv clean; @@ -585,12 +586,12 @@ TEST(MsvcSdk, ACompleteRootIsStillAccepted) { t.add_sdk("10.0.26100.0"); std::array roots{t.root}; auto sdk = msvc::find_windows_sdk(roots); - ASSERT_TRUE(sdk) << "a complete SDK root was rejected"; - EXPECT_EQ(sdk->root, t.root); - EXPECT_EQ(sdk->version, "10.0.26100.0"); -} + expect(fatal(bool(sdk))) << "a complete SDK root was rejected"; + expect(eq(sdk->root, t.root)); + expect(eq(sdk->version, "10.0.26100.0")); +}}; -TEST(MsvcSdk, APartialRootYieldsToACompleteOne) { +UTest a_partial_root_yields_to_a_complete_one{"MsvcSdk/APartialRootYieldsToACompleteOne", [] { // Ordering, not just acceptance: given both, the usable one must win even // though the partial one carries the HIGHER version — which is how the // managed payload outranked the system SDK in the first place. @@ -601,12 +602,12 @@ TEST(MsvcSdk, APartialRootYieldsToACompleteOne) { complete.add_sdk("10.0.26100.0"); std::array roots{partial.root, complete.root}; auto sdk = msvc::find_windows_sdk(roots); - ASSERT_TRUE(sdk); - EXPECT_EQ(sdk->root, complete.root) + expect(fatal(bool(sdk))); + expect(eq(sdk->root, complete.root)) << "the partial root won on version; it cannot link"; -} +}}; -TEST(MsvcSdk, DeclaredRootOutranksTheManagedOne) { +UTest declared_root_outranks_the_managed_one{"MsvcSdk/DeclaredRootOutranksTheManagedOne", [] { // Both available. WindowsSdkDir is someone saying which one to use, and // it has to win — the same precedence VSINSTALLDIR has over vswhere. NoSdkEnv clean; @@ -617,30 +618,30 @@ TEST(MsvcSdk, DeclaredRootOutranksTheManagedOne) { ScopedEnv dir{"WindowsSdkDir", declared.root.string()}; std::array roots{managed.root}; auto sdk = msvc::find_windows_sdk(roots); - ASSERT_TRUE(sdk.has_value()); - EXPECT_EQ(sdk->root, declared.root); - EXPECT_EQ(sdk->version, "10.0.22621.0"); -} + expect(fatal(sdk.has_value())); + expect(eq(sdk->root, declared.root)); + expect(eq(sdk->version, "10.0.22621.0")); +}}; -TEST(MsvcSdk, SiblingSdkRootsAreEmptyForACompilerOutsideAnyStore) { +UTest sibling_sdk_roots_are_empty_for_a_compiler_outside_any_store{"MsvcSdk/SiblingSdkRootsAreEmptyForACompilerOutsideAnyStore", [] { // A system cl.exe is not in an xlings store, so there is nothing to // offer — and offering the wrong thing would be worse than nothing. - EXPECT_TRUE(msvc::sibling_sdk_roots( + expect(msvc::sibling_sdk_roots( "C:/Program Files/Microsoft Visual Studio/18/Enterprise/VC/Tools/" "MSVC/14.51.36231/bin/Hostx64/x64/cl.exe").empty()); -} +}}; // ─── model traits ──────────────────────────────────────────────────────── -TEST(MsvcModel, BmiTraitsUseIfc) { +UTest bmi_traits_use_ifc{"MsvcModel/BmiTraitsUseIfc", [] { Toolchain tc; tc.compiler = CompilerId::MSVC; auto t = bmi_traits(tc); - EXPECT_EQ(t.bmiDir, "ifc.cache"); - EXPECT_EQ(t.bmiExt, ".ifc"); - EXPECT_TRUE(t.needsExplicitModuleOutput); - EXPECT_FALSE(t.scanNeedsFModules); -} + expect(eq(t.bmiDir, "ifc.cache")); + expect(eq(t.bmiExt, ".ifc")); + expect(bool(t.needsExplicitModuleOutput)); + expect(!t.scanNeedsFModules); +}}; // ─── std module minimum standard level ─────────────────────────────────── // @@ -650,7 +651,7 @@ TEST(MsvcModel, BmiTraitsUseIfc) { // refuse with an actionable message instead. // See .agents/docs/2026-07-31-cpp20-standard-support-design.md §2.3. -TEST(MsvcStdModule, MinLevelFollowsStlUnblockVersion) { +UTest min_level_follows_stl_unblock_version{"MsvcStdModule/MinLevelFollowsStlUnblockVersion", [] { auto tc_of = [](std::string ver) { Toolchain tc; tc.compiler = CompilerId::MSVC; @@ -658,16 +659,16 @@ TEST(MsvcStdModule, MinLevelFollowsStlUnblockVersion) { return tc; }; // VS 2022 17.8 and newer: C++20 is allowed. - EXPECT_EQ(msvc::std_module_min_level(tc_of("19.38.33130")), 20); - EXPECT_EQ(msvc::std_module_min_level(tc_of("19.44.35211")), 20); - EXPECT_EQ(msvc::std_module_min_level(tc_of("20.0")), 20); + expect(eq(msvc::std_module_min_level(tc_of("19.38.33130")), 20)); + expect(eq(msvc::std_module_min_level(tc_of("19.44.35211")), 20)); + expect(eq(msvc::std_module_min_level(tc_of("20.0")), 20)); // Older toolsets keep the C++23 floor. - EXPECT_EQ(msvc::std_module_min_level(tc_of("19.37.32825")), 23); - EXPECT_EQ(msvc::std_module_min_level(tc_of("19.29.30153")), 23); + expect(eq(msvc::std_module_min_level(tc_of("19.37.32825")), 23)); + expect(eq(msvc::std_module_min_level(tc_of("19.29.30153")), 23)); // Unparseable banner version: stay strict rather than guess. - EXPECT_EQ(msvc::std_module_min_level(tc_of("")), 23); - EXPECT_EQ(msvc::std_module_min_level(tc_of("unknown")), 23); -} + expect(eq(msvc::std_module_min_level(tc_of("")), 23)); + expect(eq(msvc::std_module_min_level(tc_of("unknown")), 23)); +}}; // #603 — THE QUESTION IS ASKED OF THE STL, NOT OF WHATEVER COMPILER REACHES IT. // @@ -680,28 +681,28 @@ TEST(MsvcStdModule, MinLevelFollowsStlUnblockVersion) { // because it would compare a CLANG version number against an MSVC threshold and // be right by accident: clang 20.x passes `>= 19.38` and clang 19.x fails it. // The toolset version is in the path of the module source that was selected. -TEST(MsvcStdModule, MinLevelForStlReadsTheToolsetOutOfTheModuleSourcePath) { +UTest min_level_for_stl_reads_the_toolset_out_of_the_module_source_path{"MsvcStdModule/MinLevelForStlReadsTheToolsetOutOfTheModuleSourcePath", [] { auto lvl = [](std::string p) { return msvc::std_module_min_level_for_stl(std::filesystem::path(p)); }; // VS 2022 17.8 (toolset 14.38) is where microsoft/STL#3977 first ships. - EXPECT_EQ(lvl("C:/VS/VC/Tools/MSVC/14.38.33130/modules/std.ixx"), 20); - EXPECT_EQ(lvl("C:/VS/VC/Tools/MSVC/14.44.35207/modules/std.ixx"), 20); + expect(eq(lvl("C:/VS/VC/Tools/MSVC/14.38.33130/modules/std.ixx"), 20)); + expect(eq(lvl("C:/VS/VC/Tools/MSVC/14.44.35207/modules/std.ixx"), 20)); // Older toolsets keep the C++23 floor and get an actionable diagnostic. - EXPECT_EQ(lvl("C:/VS/VC/Tools/MSVC/14.37.32822/modules/std.ixx"), 23); - EXPECT_EQ(lvl("C:/VS/VC/Tools/MSVC/14.29.30133/modules/std.ixx"), 23); + expect(eq(lvl("C:/VS/VC/Tools/MSVC/14.37.32822/modules/std.ixx"), 23)); + expect(eq(lvl("C:/VS/VC/Tools/MSVC/14.29.30133/modules/std.ixx"), 23)); // A layout this mapping is not defined for answers 23 rather than guessing. // The safety the hardcode was after is kept for exactly these cases. - EXPECT_EQ(lvl(""), 23); - EXPECT_EQ(lvl("C:/VS/VC/Tools/MSVC/modules/std.ixx"), 23); - EXPECT_EQ(lvl("/opt/llvm/share/libc++/v1/std.cppm"), 23); - EXPECT_EQ(lvl("C:/VS/VC/Tools/MSVC/15.0.0/modules/std.ixx"), 23); -} + expect(eq(lvl(""), 23)); + expect(eq(lvl("C:/VS/VC/Tools/MSVC/modules/std.ixx"), 23)); + expect(eq(lvl("/opt/llvm/share/libc++/v1/std.cppm"), 23)); + expect(eq(lvl("C:/VS/VC/Tools/MSVC/15.0.0/modules/std.ixx"), 23)); +}}; // The two forms must agree for a well-formed installation, because that is the // one path the banner form was verified on. If they could disagree there, this // change would be a silent behaviour change on the toolchain that worked. -TEST(MsvcStdModule, TheStlAndBannerFormsAgreeForAWellFormedInstallation) { +UTest the_stl_and_banner_forms_agree_for_a_well_formed_installation{"MsvcStdModule/TheStlAndBannerFormsAgreeForAWellFormedInstallation", [] { auto tc_of = [](std::string ver) { Toolchain tc; tc.compiler = CompilerId::MSVC; @@ -714,13 +715,12 @@ TEST(MsvcStdModule, TheStlAndBannerFormsAgreeForAWellFormedInstallation) { std::pair{"19.44.35211", "14.44.35207"}, std::pair{"19.37.32825", "14.37.32822"}, std::pair{"19.29.30153", "14.29.30133"} }) { - EXPECT_EQ(msvc::std_module_min_level(tc_of(banner)), - msvc::std_module_min_level_for_stl(std::filesystem::path( + expect(eq(msvc::std_module_min_level(tc_of(banner)), msvc::std_module_min_level_for_stl(std::filesystem::path( std::string("C:/VS/VC/Tools/MSVC/") + toolset - + "/modules/std.ixx"))) + + "/modules/std.ixx")))) << "banner " << banner << " vs toolset " << toolset; } -} +}}; // #422 — the std module must be built with the SAME CRT model as the TUs that // import it. @@ -737,7 +737,7 @@ TEST(MsvcStdModule, TheStlAndBannerFormsAgreeForAWellFormedInstallation) { // and it must be in the command STRING (so it enters `std_build_commands`, // which is part of the std cache identity — two CRT models then cannot share a // cache directory and silently serve each other's module). -TEST(ToolchainMsvc, StdModuleCarriesTheProjectCrtModel) { +UTest std_module_carries_the_project_crt_model{"ToolchainMsvc/StdModuleCarriesTheProjectCrtModel", [] { Toolchain tc; tc.compiler = CompilerId::MSVC; tc.binaryPath = "C:/vc/bin/cl.exe"; @@ -748,63 +748,60 @@ TEST(ToolchainMsvc, StdModuleCarriesTheProjectCrtModel) { for (std::string_view crt : {std::string_view("/MT"), std::string_view("/MD")}) { auto cmds = msvc::std_module_build_commands(tc, cache, "/std:c++23", crt); - ASSERT_EQ(cmds.size(), 1u); - EXPECT_NE(cmds[0].find(crt), std::string::npos) - << crt << " missing from the std build command: " << cmds[0]; + expect(fatal(eq(cmds.size(), 1u))); + if (auto&& failed = expect(ne(cmds[0].find(crt), std::string::npos)); !failed) failed << crt << " missing from the std build command: " << cmds[0]; auto compat = msvc::std_compat_build_commands(tc, cache, "/std:c++23", crt); - ASSERT_EQ(compat.size(), 1u); - EXPECT_NE(compat[0].find(crt), std::string::npos) - << crt << " missing from the std.compat build command: " << compat[0]; + expect(fatal(eq(compat.size(), 1u))); + if (auto&& failed = expect(ne(compat[0].find(crt), std::string::npos)); !failed) failed << crt << " missing from the std.compat build command: " << compat[0]; } // The two models must produce DIFFERENT command strings. Equal strings would // mean equal cache keys, i.e. the exact silent divergence this fixes. - EXPECT_NE(msvc::std_module_build_commands(tc, cache, "/std:c++23", "/MT")[0], - msvc::std_module_build_commands(tc, cache, "/std:c++23", "/MD")[0]); + expect(ne(msvc::std_module_build_commands(tc, cache, "/std:c++23", "/MT")[0], msvc::std_module_build_commands(tc, cache, "/std:c++23", "/MD")[0])); // Empty keeps cl's own default, so non-MSVC callers are unaffected. auto bare = msvc::std_module_build_commands(tc, cache, "/std:c++23"); - EXPECT_EQ(bare[0].find("/MT"), std::string::npos); - EXPECT_EQ(bare[0].find("/MD"), std::string::npos); -} + expect(eq(bare[0].find("/MT"), std::string::npos)); + expect(eq(bare[0].find("/MD"), std::string::npos)); +}}; // The mapping linkage -> CRT model lives in ONE place. It was derived twice — // flags.cppm for the project's TUs, and cl's default for the std module — which // is how they disagreed. -TEST(ToolchainMsvc, CrtFlagHasASingleDerivation) { +UTest crt_flag_has_a_single_derivation{"ToolchainMsvc/CrtFlagHasASingleDerivation", [] { Toolchain cl; cl.compiler = CompilerId::MSVC; const auto& msvcDialect = dialect_for(cl); - EXPECT_EQ(msvc_crt_flag(msvcDialect, /*staticCrt=*/true), "/MT"); - EXPECT_EQ(msvc_crt_flag(msvcDialect, /*staticCrt=*/false), "/MD"); + expect(eq(msvc_crt_flag(msvcDialect, /*staticCrt=*/true), "/MT")); + expect(eq(msvc_crt_flag(msvcDialect, /*staticCrt=*/false), "/MD")); // GNU has no counterpart; the helper must yield nothing rather than invent // a flag that would be passed to gcc. Toolchain gcc; gcc.compiler = CompilerId::GCC; const auto& gnu = dialect_for(gcc); - EXPECT_TRUE(msvc_crt_flag(gnu, false).empty()); -} + expect(msvc_crt_flag(gnu, false).empty()); +}}; // Both manifest keys reach the SAME physical switch, because on the MSVC ABI // they are the same switch. `cxx_runtime = "self-contained"` used to report // "not implemented" while `linkage = "static"` quietly did the very thing it // said was unimplemented. -TEST(ToolchainMsvc, BothKeysSelectTheStaticCrt) { - EXPECT_TRUE(msvc_wants_static_crt("static", "")); - EXPECT_TRUE(msvc_wants_static_crt("", "self-contained")); - EXPECT_TRUE(msvc_wants_static_crt("static", "self-contained")); +UTest both_keys_select_the_static_crt{"ToolchainMsvc/BothKeysSelectTheStaticCrt", [] { + expect(bool(msvc_wants_static_crt("static", ""))); + expect(bool(msvc_wants_static_crt("", "self-contained"))); + expect(bool(msvc_wants_static_crt("static", "self-contained"))); // Default: neither written down. This has to stay /MD — most roles // DEFAULT to the self-contained contract, so keying off the resolved // contract instead of the written key would flip every Windows build to // /MT and split the CRT across a project's dependencies. - EXPECT_FALSE(msvc_wants_static_crt("", "")); - EXPECT_FALSE(msvc_wants_static_crt("dynamic", "")); - EXPECT_FALSE(msvc_wants_static_crt("", "host-coupled")); - EXPECT_FALSE(msvc_wants_static_crt("", "toolchain-coupled")); -} + expect(!msvc_wants_static_crt("", "")); + expect(!msvc_wants_static_crt("dynamic", "")); + expect(!msvc_wants_static_crt("", "host-coupled")); + expect(!msvc_wants_static_crt("", "toolchain-coupled")); +}}; // ─── the SDK axis: bound for a managed toolset, searched for a system one ── // @@ -865,28 +862,28 @@ struct FakeStore { } // namespace -TEST(MsvcSdkOrigin, ACompilerInAStoreIsManagedAndOneOutsideIsNot) { +UTest a_compiler_in_a_store_is_managed_and_one_outside_is_not{"MsvcSdkOrigin/ACompilerInAStoreIsManagedAndOneOutsideIsNot", [] { FakeStore store; auto cl = store.add_toolset("14.44.35207"); - EXPECT_EQ(msvc::origin_of(cl), Origin::Managed); - EXPECT_EQ(msvc::origin_of( + expect(eq(msvc::origin_of(cl), Origin::Managed)); + expect(eq(msvc::origin_of( "C:/Program Files/Microsoft Visual Studio/18/Enterprise/VC/Tools/" - "MSVC/14.51.36231/bin/Hostx64/x64/cl.exe"), Origin::SystemMsvc); -} + "MSVC/14.51.36231/bin/Hostx64/x64/cl.exe"), Origin::SystemMsvc)); +}}; -TEST(MsvcSdkOrigin, AManagedToolsetTakesTheSdkFromItsOwnStore) { +UTest a_managed_toolset_takes_the_sdk_from_its_own_store{"MsvcSdkOrigin/AManagedToolsetTakesTheSdkFromItsOwnStore", [] { NoSdkEnv clean; FakeStore store; auto cl = store.add_toolset("14.44.35207"); store.add_sdk("10.0.26100.0"); auto choice = msvc::resolve_sdk_for(cl); - ASSERT_TRUE(choice.sdk.has_value()) + expect(fatal(choice.sdk.has_value())) << "the toolset's own SDK payload was not found"; - EXPECT_EQ(choice.origin, Origin::Managed); - EXPECT_EQ(choice.sdk->version, "10.0.26100.0"); - EXPECT_TRUE(choice.note.empty()) << choice.note; -} + expect(eq(choice.origin, Origin::Managed)); + expect(eq(choice.sdk->version, "10.0.26100.0")); + expect(choice.note.empty()) << choice.note; +}}; // THE CRITERION THIS AXIS EXISTS FOR. An environment that can overwrite the // choice means the manifest did not pin anything — it only expressed a @@ -895,7 +892,7 @@ TEST(MsvcSdkOrigin, AManagedToolsetTakesTheSdkFromItsOwnStore) { // This is the design doc's §6 acceptance test, as a unit test: point // WindowsSdkDir somewhere else entirely and the build must still use the // payload's SDK, and must SAY that the variable was ignored. -TEST(MsvcSdkOrigin, WindowsSdkDirCannotOverrideAPinnedToolsetsSdk) { +UTest windows_sdk_dir_cannot_override_a_pinned_toolsets_sdk{"MsvcSdkOrigin/WindowsSdkDirCannotOverrideAPinnedToolsetsSdk", [] { NoSdkEnv clean; FakeStore store; auto cl = store.add_toolset("14.44.35207"); @@ -906,16 +903,16 @@ TEST(MsvcSdkOrigin, WindowsSdkDirCannotOverrideAPinnedToolsetsSdk) { ScopedEnv dir{"WindowsSdkDir", elsewhere.root.string()}; auto choice = msvc::resolve_sdk_for(cl); - ASSERT_TRUE(choice.sdk.has_value()); - EXPECT_EQ(choice.sdk->version, "10.0.26100.0") + expect(fatal(choice.sdk.has_value())); + expect(eq(choice.sdk->version, "10.0.26100.0")) << "the environment overrode a pinned toolset's SDK; the pin is not one"; - EXPECT_NE(choice.sdk->root, elsewhere.root); - EXPECT_NE(choice.note.find("WindowsSdkDir"), std::string::npos) + expect(ne(choice.sdk->root, elsewhere.root)); + expect(ne(choice.note.find("WindowsSdkDir"), std::string::npos)) << "an ignored override that says nothing is indistinguishable from " "one that was never set: " << choice.note; -} +}}; -TEST(MsvcSdkOrigin, WindowsSdkVersionDoesNotPickAmongPayloadsEither) { +UTest windows_sdk_version_does_not_pick_among_payloads_either{"MsvcSdkOrigin/WindowsSdkVersionDoesNotPickAmongPayloadsEither", [] { // Same override wearing a smaller hat: naming a version is still the // environment choosing which headers a pinned build compiles against. NoSdkEnv clean; @@ -926,13 +923,13 @@ TEST(MsvcSdkOrigin, WindowsSdkVersionDoesNotPickAmongPayloadsEither) { ScopedEnv ver{"WindowsSdkVersion", "10.0.22621.0\\"}; auto choice = msvc::resolve_sdk_for(cl); - ASSERT_TRUE(choice.sdk.has_value()); - EXPECT_EQ(choice.sdk->version, "10.0.26100.0") + expect(fatal(choice.sdk.has_value())); + expect(eq(choice.sdk->version, "10.0.26100.0")) << "WindowsSdkVersion selected among the store's payloads"; - EXPECT_FALSE(choice.note.empty()); -} + expect(!choice.note.empty()); +}}; -TEST(MsvcSdkOrigin, AManagedToolsetWithNoSdkPayloadFallsBackAndSaysSo) { +UTest a_managed_toolset_with_no_sdk_payload_falls_back_and_says_so{"MsvcSdkOrigin/AManagedToolsetWithNoSdkPayloadFallsBackAndSaysSo", [] { // Working beats failing here — the machine may well have a complete SDK, // and refusing to build would be a regression for anyone whose toolset // predates the SDK dependency. What must not happen is the fallback being @@ -947,12 +944,12 @@ TEST(MsvcSdkOrigin, AManagedToolsetWithNoSdkPayloadFallsBackAndSaysSo) { ScopedEnv dir{"WindowsSdkDir", machine.root.string()}; auto choice = msvc::resolve_sdk_for(cl); - ASSERT_TRUE(choice.sdk.has_value()); - EXPECT_EQ(choice.sdk->root, machine.root); - EXPECT_NE(choice.note.find("machine"), std::string::npos) << choice.note; -} + expect(fatal(choice.sdk.has_value())); + expect(eq(choice.sdk->root, machine.root)); + expect(ne(choice.note.find("machine"), std::string::npos)) << choice.note; +}}; -TEST(MsvcSdkOrigin, ASystemToolsetKeepsTheDeclaredSearchChain) { +UTest a_system_toolset_keeps_the_declared_search_chain{"MsvcSdkOrigin/ASystemToolsetKeepsTheDeclaredSearchChain", [] { // The other origin is unchanged, and must be: a machine's things can only // be found by looking, and WindowsSdkDir is the most specific answer // available there — the same precedence VSINSTALLDIR has over vswhere. @@ -966,12 +963,12 @@ TEST(MsvcSdkOrigin, ASystemToolsetKeepsTheDeclaredSearchChain) { auto cl = vs.root / "VC" / "Tools" / "MSVC" / "14.51.36231" / "bin" / "Hostx64" / "x64" / "cl.exe"; auto choice = msvc::resolve_sdk_for(cl); - EXPECT_EQ(choice.origin, Origin::SystemMsvc); - ASSERT_TRUE(choice.sdk.has_value()); - EXPECT_EQ(choice.sdk->root, declared.root); - EXPECT_TRUE(choice.note.empty()) + expect(eq(choice.origin, Origin::SystemMsvc)); + expect(fatal(choice.sdk.has_value())); + expect(eq(choice.sdk->root, declared.root)); + expect(choice.note.empty()) << "nothing was ignored, so there is nothing to report: " << choice.note; -} +}}; // ─── Toolset selection: one rule for the cl.exe row and the clang row ───── // @@ -1032,53 +1029,53 @@ struct TwoInstances { } // namespace -TEST(MsvcToolsetVersion, ComparedAsNumbersNotStrings) { - EXPECT_LT(msvc::compare_toolset_versions("14.9", "14.10"), 0); - EXPECT_GT(msvc::compare_toolset_versions("14.44.35207", "14.38.33130"), 0); - EXPECT_EQ(msvc::compare_toolset_versions("14.44", "14.44.0"), 0); - EXPECT_EQ(msvc::compare_toolset_versions("17.14.36301.6", "17.14.36301.6"), 0); -} - -TEST(MsvcToolsetVersion, APrefixMatchesWholeComponents) { - EXPECT_TRUE(msvc::toolset_version_matches("14.44", "14.44.35207")); - EXPECT_TRUE(msvc::toolset_version_matches("14.44.35207", "14.44.35207")); - EXPECT_TRUE(msvc::toolset_version_matches("14", "14.51.36014")); +UTest compared_as_numbers_not_strings{"MsvcToolsetVersion/ComparedAsNumbersNotStrings", [] { + expect(lt(msvc::compare_toolset_versions("14.9", "14.10"), 0)); + expect(gt(msvc::compare_toolset_versions("14.44.35207", "14.38.33130"), 0)); + expect(eq(msvc::compare_toolset_versions("14.44", "14.44.0"), 0)); + expect(eq(msvc::compare_toolset_versions("17.14.36301.6", "17.14.36301.6"), 0)); +}}; + +UTest a_prefix_matches_whole_components{"MsvcToolsetVersion/APrefixMatchesWholeComponents", [] { + expect(msvc::toolset_version_matches("14.44", "14.44.35207")); + expect(msvc::toolset_version_matches("14.44.35207", "14.44.35207")); + expect(msvc::toolset_version_matches("14", "14.51.36014")); // `14.4` is the 14.4 line, not a text prefix of 14.44. - EXPECT_FALSE(msvc::toolset_version_matches("14.4", "14.44.35207")); - EXPECT_FALSE(msvc::toolset_version_matches("14.44.35207.1", "14.44.35207")); - EXPECT_FALSE(msvc::toolset_version_matches("", "14.44.35207")); -} + expect(!msvc::toolset_version_matches("14.4", "14.44.35207")); + expect(!msvc::toolset_version_matches("14.44.35207.1", "14.44.35207")); + expect(!msvc::toolset_version_matches("", "14.44.35207")); +}}; -TEST(MsvcToolsetSelect, APinnedVersionIsFoundInWhicheverInstanceHasIt) { +UTest a_pinned_version_is_found_in_whichever_instance_has_it{"MsvcToolsetSelect/APinnedVersionIsFoundInWhicheverInstanceHasIt", [] { TwoInstances m; msvc::ToolsetNeeds needs; auto a = msvc::select_system_toolset(m.all(), {}, "14.38", needs); - ASSERT_TRUE(a.has_value()); - EXPECT_EQ(a->version, "14.38.33130"); - EXPECT_EQ(a->toolsDir, m.vs2022.tools("14.38.33130")); - EXPECT_EQ(a->via, "pinned"); + expect(fatal(a.has_value())); + expect(eq(a->version, "14.38.33130")); + expect(eq(a->toolsDir, m.vs2022.tools("14.38.33130"))); + expect(eq(a->via, "pinned")); auto b = msvc::select_system_toolset(m.all(), {}, "14.51.36014", needs); - ASSERT_TRUE(b.has_value()); - EXPECT_EQ(b->vsRoot, m.vs18.dir.root); - EXPECT_EQ(b->product, "Visual Studio Community 18 Insiders"); + expect(fatal(b.has_value())); + expect(eq(b->vsRoot, m.vs18.dir.root)); + expect(eq(b->product, "Visual Studio Community 18 Insiders")); // The highest match across every instance, by number. auto c = msvc::select_system_toolset(m.all(), {}, "14", needs); - ASSERT_TRUE(c.has_value()); - EXPECT_EQ(c->version, "14.51.36014"); -} + expect(fatal(c.has_value())); + expect(eq(c->version, "14.51.36014")); +}}; -TEST(MsvcToolsetSelect, APinWithNoInstalledMatchIsNulloptSoThePackageIsUsed) { +UTest a_pin_with_no_installed_match_is_nullopt_so_the_package_is_used{"MsvcToolsetSelect/APinWithNoInstalledMatchIsNulloptSoThePackageIsUsed", [] { TwoInstances m; - EXPECT_FALSE(msvc::select_system_toolset(m.all(), {}, "14.29", - msvc::ToolsetNeeds{}).has_value()); + expect(!(msvc::select_system_toolset(m.all(), {}, "14.29", + msvc::ToolsetNeeds{}).has_value())); // `14.4` does not reach 14.44 by text. - EXPECT_FALSE(msvc::select_system_toolset(m.all(), {}, "14.4", - msvc::ToolsetNeeds{}).has_value()); -} + expect(!(msvc::select_system_toolset(m.all(), {}, "14.4", + msvc::ToolsetNeeds{}).has_value())); +}}; -TEST(MsvcToolsetSelect, APinIgnoresTheEnvironmentAndSaysSo) { +UTest a_pin_ignores_the_environment_and_says_so{"MsvcToolsetSelect/APinIgnoresTheEnvironmentAndSaysSo", [] { // A developer prompt opened with `-vcvars_ver=14.38` exports this, and a // pinned manifest must not follow it. TwoInstances m; @@ -1087,57 +1084,57 @@ TEST(MsvcToolsetSelect, APinIgnoresTheEnvironmentAndSaysSo) { env.vsInstallDir = m.vs2022.dir.root; auto c = msvc::select_system_toolset(m.all(), env, "14.44.35207", msvc::ToolsetNeeds{}); - ASSERT_TRUE(c.has_value()); - EXPECT_EQ(c->version, "14.44.35207"); + expect(fatal(c.has_value())); + expect(eq(c->version, "14.44.35207")); bool noted = false; for (auto const& n : c->notes) noted = noted || (n.find("VCToolsInstallDir") != std::string::npos && n.find("14.38.33130") != std::string::npos); - EXPECT_TRUE(noted) << "an ignored declaration has to be reported"; -} + expect(bool(noted)) << "an ignored declaration has to be reported"; +}}; -TEST(MsvcToolsetSelect, AnIncompleteMatchIsSkippedAndReported) { +UTest an_incomplete_match_is_skipped_and_reported{"MsvcToolsetSelect/AnIncompleteMatchIsSkippedAndReported", [] { TwoInstances m; m.vs18.toolset("14.44.35210", /*complete=*/false); // newer, but no lib/x64 auto c = msvc::select_system_toolset(m.all(), {}, "14.44", msvc::ToolsetNeeds{}); - ASSERT_TRUE(c.has_value()); - EXPECT_EQ(c->version, "14.44.35207"); - ASSERT_FALSE(c->notes.empty()); - EXPECT_NE(c->notes.front().find("14.44.35210"), std::string::npos); -} + expect(fatal(c.has_value())); + expect(eq(c->version, "14.44.35207")); + expect(fatal(!(c->notes.empty()))); + expect(ne(c->notes.front().find("14.44.35210"), std::string::npos)); +}}; -TEST(MsvcToolsetSelect, SystemFollowsTheDeclarationsInOrder) { +UTest system_follows_the_declarations_in_order{"MsvcToolsetSelect/SystemFollowsTheDeclarationsInOrder", [] { TwoInstances m; msvc::ToolsetNeeds needs; msvc::MsvcEnvSnapshot byTools; byTools.vcToolsInstallDir = m.vs2022.tools("14.38.33130"); auto a = msvc::select_system_toolset(m.all(), byTools, "system", needs); - ASSERT_TRUE(a.has_value()); - EXPECT_EQ(a->version, "14.38.33130"); - EXPECT_EQ(a->via, "VCToolsInstallDir"); + expect(fatal(a.has_value())); + expect(eq(a->version, "14.38.33130")); + expect(eq(a->via, "VCToolsInstallDir")); msvc::MsvcEnvSnapshot byInstance; byInstance.vsInstallDir = m.vs2022.dir.root; auto b = msvc::select_system_toolset(m.all(), byInstance, "system", needs); - ASSERT_TRUE(b.has_value()); - EXPECT_EQ(b->version, "14.44.35207") << "the instance's default, not its highest"; - EXPECT_EQ(b->via, "VSINSTALLDIR"); + expect(fatal(b.has_value())); + expect(eq(b->version, "14.44.35207")) << "the instance's default, not its highest"; + expect(eq(b->via, "VSINSTALLDIR")); msvc::MsvcEnvSnapshot byPath; byPath.clOnPath = m.vs2022.tools("14.38.33130") / "bin" / "Hostx64" / "x64" / "cl.exe"; auto c = msvc::select_system_toolset(m.all(), byPath, "system", needs); - ASSERT_TRUE(c.has_value()); - EXPECT_EQ(c->version, "14.38.33130"); - EXPECT_EQ(c->via, "PATH"); + expect(fatal(c.has_value())); + expect(eq(c->version, "14.38.33130")); + expect(eq(c->via, "PATH")); auto d = msvc::select_system_toolset(m.all(), {}, "system", needs); - ASSERT_TRUE(d.has_value()); - EXPECT_EQ(d->version, "14.51.36014") << "the newest installation's default"; - EXPECT_EQ(d->via, "newest instance"); -} + expect(fatal(d.has_value())); + expect(eq(d->version, "14.51.36014")) << "the newest installation's default"; + expect(eq(d->via, "newest instance")); +}}; -TEST(MsvcToolsetSelect, TheDefaultFileOutranksTheHighestDirectory) { +UTest the_default_file_outranks_the_highest_directory{"MsvcToolsetSelect/TheDefaultFileOutranksTheHighestDirectory", [] { // A side-by-side toolset newer than the instance's default is what VS's // own vcvars would NOT pick; neither does this. FakeInstance vs{"sel-default", "Visual Studio Community 2022", "17.14.1"}; @@ -1145,11 +1142,11 @@ TEST(MsvcToolsetSelect, TheDefaultFileOutranksTheHighestDirectory) { vs.toolset("14.50.35717"); vs.default_is("14.44.35207"); auto c = msvc::select_system_toolset({vs.inst}, {}, "system", msvc::ToolsetNeeds{}); - ASSERT_TRUE(c.has_value()); - EXPECT_EQ(c->version, "14.44.35207"); -} + expect(fatal(c.has_value())); + expect(eq(c->version, "14.44.35207")); +}}; -TEST(MsvcToolsetSelect, TheNewestInstanceWithoutAToolsetIsPassedOver) { +UTest the_newest_instance_without_a_toolset_is_passed_over{"MsvcToolsetSelect/TheNewestInstanceWithoutAToolsetIsPassedOver", [] { // Visual Studio installed without the C++ workload: newest by installation // version, and nothing a build can use. TwoInstances m; @@ -1157,81 +1154,81 @@ TEST(MsvcToolsetSelect, TheNewestInstanceWithoutAToolsetIsPassedOver) { auto all = m.all(); all.insert(all.begin(), bare.inst); auto c = msvc::select_system_toolset(all, {}, "system", msvc::ToolsetNeeds{}); - ASSERT_TRUE(c.has_value()); - EXPECT_EQ(c->version, "14.51.36014"); -} + expect(fatal(c.has_value())); + expect(eq(c->version, "14.51.36014")); +}}; -TEST(MsvcToolsetSelect, TheClangRowDoesNotNeedClExe) { +UTest the_clang_row_does_not_need_cl_exe{"MsvcToolsetSelect/TheClangRowDoesNotNeedClExe", [] { FakeInstance vs{"sel-nocl", "Visual Studio Build Tools 2022", "17.14.1"}; vs.toolset("14.44.35207", /*complete=*/true, /*withCl=*/false); msvc::ToolsetNeeds clRow; msvc::ToolsetNeeds clangRow; clangRow.cl = false; - EXPECT_FALSE(msvc::select_system_toolset({vs.inst}, {}, "system", clRow).has_value()); + expect(!(msvc::select_system_toolset({vs.inst}, {}, "system", clRow).has_value())); auto c = msvc::select_system_toolset({vs.inst}, {}, "system", clangRow); - ASSERT_TRUE(c.has_value()); - EXPECT_EQ(c->version, "14.44.35207"); -} + expect(fatal(c.has_value())); + expect(eq(c->version, "14.44.35207")); +}}; -TEST(MsvcToolsetSelect, TheRefusalListsWhatIsInstalled) { +UTest the_refusal_lists_what_is_installed{"MsvcToolsetSelect/TheRefusalListsWhatIsInstalled", [] { TwoInstances m; auto lines = msvc::describe_system_toolsets(m.all(), msvc::ToolsetNeeds{}); - ASSERT_EQ(lines.size(), 3u); + expect(fatal(eq(lines.size(), 3u))); bool defaultMarked = false; for (auto const& l : lines) defaultMarked = defaultMarked || (l.find("14.44.35207") != std::string::npos && l.find("(default)") != std::string::npos); - EXPECT_TRUE(defaultMarked); -} + expect(bool(defaultMarked)); +}}; // ─── `xim:` names the ecosystem package ────────────────────────────────── -TEST(ToolchainSpelling, XimMsvcIsThePackageOnly) { +UTest xim_msvc_is_the_package_only{"ToolchainSpelling/XimMsvcIsThePackageOnly", [] { auto spec = parse_toolchain_spec("xim:msvc@14.44.35207"); - ASSERT_TRUE(spec.has_value()) << spec.error(); - EXPECT_EQ(spec->family, Family::Msvc); - EXPECT_EQ(spec->version, "14.44.35207"); - EXPECT_TRUE(spec->ecosystemOnly); - EXPECT_FALSE(is_system_toolchain(*spec)); + if (auto&& failed = expect(fatal(spec.has_value())); !failed) failed << spec.error(); + expect(eq(spec->family, Family::Msvc)); + expect(eq(spec->version, "14.44.35207")); + expect(bool(spec->ecosystemOnly)); + expect(!is_system_toolchain(*spec)); // The prefix changes the meaning for msvc, so it survives persistence. - EXPECT_EQ(spec->spec_str(), "xim:msvc@14.44.35207"); - EXPECT_EQ(to_xim_package(*spec).target(), "xim:msvc@14.44.35207"); + expect(eq(spec->spec_str(), "xim:msvc@14.44.35207")); + expect(eq(to_xim_package(*spec).target(), "xim:msvc@14.44.35207")); auto bare = parse_toolchain_spec("msvc@14.44.35207"); - ASSERT_TRUE(bare.has_value()); - EXPECT_FALSE(bare->ecosystemOnly); - EXPECT_EQ(bare->spec_str(), "msvc@14.44.35207"); -} + expect(fatal(bare.has_value())); + expect(!bare->ecosystemOnly); + expect(eq(bare->spec_str(), "msvc@14.44.35207")); +}}; -TEST(ToolchainSpelling, XimIsASynonymWhereThereIsNoSystemOrigin) { +UTest xim_is_a_synonym_where_there_is_no_system_origin{"ToolchainSpelling/XimIsASynonymWhereThereIsNoSystemOrigin", [] { for (auto [prefixed, bare] : {std::pair{"xim:gcc@16.1.0", "gcc@16.1.0"}, std::pair{"xim:llvm@22.1.8", "llvm@22.1.8"}}) { auto a = parse_toolchain_spec(prefixed); auto b = parse_toolchain_spec(bare); - ASSERT_TRUE(a.has_value()) << prefixed; - ASSERT_TRUE(b.has_value()) << bare; - EXPECT_EQ(a->spec_str(), b->spec_str()) << "one canonical spelling"; - EXPECT_EQ(to_xim_package(*a).target(), to_xim_package(*b).target()); + expect(fatal(a.has_value())) << prefixed; + expect(fatal(b.has_value())) << bare; + expect(eq(a->spec_str(), b->spec_str())) << "one canonical spelling"; + expect(eq(to_xim_package(*a).target(), to_xim_package(*b).target())); } -} +}}; -TEST(ToolchainSpelling, XimWithSystemIsRefusedNamingBothSpellings) { +UTest xim_with_system_is_refused_naming_both_spellings{"ToolchainSpelling/XimWithSystemIsRefusedNamingBothSpellings", [] { auto spec = parse_toolchain_spec("xim:msvc@system"); - ASSERT_FALSE(spec.has_value()); - EXPECT_NE(spec.error().find("msvc@system"), std::string::npos) << spec.error(); - EXPECT_NE(spec.error().find("xim:msvc@"), std::string::npos) << spec.error(); -} + expect(fatal(!spec.has_value())); + if (auto&& failed = expect(ne(spec.error().find("msvc@system"), std::string::npos)); !failed) failed << spec.error(); + if (auto&& failed = expect(ne(spec.error().find("xim:msvc@"), std::string::npos)); !failed) failed << spec.error(); +}}; -TEST(ToolchainSpelling, AnotherNamespaceIsRefused) { +UTest another_namespace_is_refused{"ToolchainSpelling/AnotherNamespaceIsRefused", [] { auto spec = parse_toolchain_spec("foo:gcc@16.1.0"); - ASSERT_FALSE(spec.has_value()); - EXPECT_NE(spec.error().find("xim:"), std::string::npos) << spec.error(); -} + expect(fatal(!spec.has_value())); + if (auto&& failed = expect(ne(spec.error().find("xim:"), std::string::npos)); !failed) failed << spec.error(); +}}; // vswhere `-format text`: one instance per `instanceId:` line, CRLF endings, // `displayName` and not `catalog_productDisplayVersion`, and an instance with // no installation path dropped. -TEST(MsvcVswhereText, OneInstancePerInstanceId) { +UTest one_instance_per_instance_id{"MsvcVswhereText/OneInstancePerInstanceId", [] { const std::string text = "instanceId: 1a2b\r\n" "installationName: VisualStudio/17.14.8+36301.6\r\n" @@ -1248,39 +1245,39 @@ TEST(MsvcVswhereText, OneInstancePerInstanceId) { "instanceId: 5e6f\r\n" "displayName: incomplete\r\n"; auto v = mcpp::toolchain::msvc::parse_vswhere_text(text); - ASSERT_EQ(v.size(), 2u); - EXPECT_EQ(v[0].root.string(), "C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise"); - EXPECT_EQ(v[0].installVersion, "17.14.36301.6"); - EXPECT_EQ(v[0].product, "Visual Studio Enterprise 2022"); - EXPECT_EQ(v[1].root.string(), "D:\\VS\\Preview"); - EXPECT_EQ(v[1].product, "Visual Studio Community 2026 Insiders"); - EXPECT_TRUE(mcpp::toolchain::msvc::parse_vswhere_text("").empty()); -} + expect(fatal(eq(v.size(), 2u))); + expect(eq(v[0].root.string(), "C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise")); + expect(eq(v[0].installVersion, "17.14.36301.6")); + expect(eq(v[0].product, "Visual Studio Enterprise 2022")); + expect(eq(v[1].root.string(), "D:\\VS\\Preview")); + expect(eq(v[1].product, "Visual Studio Community 2026 Insiders")); + expect(mcpp::toolchain::msvc::parse_vswhere_text("").empty()); +}}; // D16: the refusal and the first-run notice say which half of the MSVC ABI is // missing and where the SDK was looked for. The sentence they replaced said // "no Visual Studio found" on a machine whose Visual Studio was found and // whose SDK sat on another drive (design 2026-10-10 §12.1, run 37974353683). -TEST(MsvcProbe, TheDescriptionNamesTheMissingHalfAndWhereItLooked) { +UTest the_description_names_the_missing_half_and_where_it_looked{"MsvcProbe/TheDescriptionNamesTheMissingHalfAndWhereItLooked", [] { namespace msvc = mcpp::toolchain::msvc; msvc::MsvcProbe vsOnly; vsOnly.visualStudio = {"D:\\VS\\2026\\Enterprise"}; vsOnly.stl = true; vsOnly.sdkSearched = {"D:\\Kits\\10", "C:\\Program Files (x86)\\Windows Kits\\10"}; const auto a = msvc::describe_msvc_probe(vsOnly); - EXPECT_NE(a.find("Visual Studio was found at D:\\VS\\2026\\Enterprise"), std::string::npos) << a; - EXPECT_NE(a.find("no Windows SDK"), std::string::npos) << a; - EXPECT_NE(a.find("D:\\Kits\\10"), std::string::npos) << a; - EXPECT_EQ(a.find("no Visual Studio"), std::string::npos) << a; + expect(ne(a.find("Visual Studio was found at D:\\VS\\2026\\Enterprise"), std::string::npos)) << a; + expect(ne(a.find("no Windows SDK"), std::string::npos)) << a; + expect(ne(a.find("D:\\Kits\\10"), std::string::npos)) << a; + expect(eq(a.find("no Visual Studio"), std::string::npos)) << a; msvc::MsvcProbe sdkOnly; sdkOnly.sdk = msvc::WindowsSdk{"D:\\Kits\\10", "10.0.26100.0"}; const auto b = msvc::describe_msvc_probe(sdkOnly); - EXPECT_NE(b.find("10.0.26100.0"), std::string::npos) << b; - EXPECT_NE(b.find("no Visual Studio"), std::string::npos) << b; + expect(ne(b.find("10.0.26100.0"), std::string::npos)) << b; + expect(ne(b.find("no Visual Studio"), std::string::npos)) << b; msvc::MsvcProbe both = vsOnly; both.sdk = sdkOnly.sdk; - EXPECT_TRUE(both.usable()); - EXPECT_TRUE(msvc::describe_msvc_probe(both).empty()); -} + expect(bool(both.usable())); + expect(msvc::describe_msvc_probe(both).empty()); +}}; diff --git a/tests/unit/test_toolchain_probe.cpp b/tests/unit/test_toolchain_probe.cpp index 8eff0b4ba..0a2be0d91 100644 --- a/tests/unit/test_toolchain_probe.cpp +++ b/tests/unit/test_toolchain_probe.cpp @@ -16,11 +16,13 @@ // // Design: .agents/docs/2026-08-08-payload-version-and-contract-drift-design.md §3.2 -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.probe; +using namespace mcpplibs::mtest; + namespace { namespace tc = mcpp::toolchain; @@ -61,50 +63,50 @@ struct TwoGlibcHome { } }; -TEST(PayloadProbe, ResolvesTheBindingExactly) { +UTest resolves_the_binding_exactly{"PayloadProbe/ResolvesTheBindingExactly", [] { TwoGlibcHome h; auto pp = tc::probe_payload_paths(h.compilerBin, "glibc@2.39"); - ASSERT_TRUE(pp.has_value()); - EXPECT_EQ(pp->glibcLib, h.libOf("2.39")) + expect(fatal(pp.has_value())); + expect(eq(pp->glibcLib, h.libOf("2.39"))) << "the authority said 2.39 and 2.44 is also installed; a resolver " "that scans would be free to return either"; -} +}}; -TEST(PayloadProbe, ResolvesTheOtherBindingExactly) { +UTest resolves_the_other_binding_exactly{"PayloadProbe/ResolvesTheOtherBindingExactly", [] { TwoGlibcHome h; auto pp = tc::probe_payload_paths(h.compilerBin, "glibc@2.44"); - ASSERT_TRUE(pp.has_value()); - EXPECT_EQ(pp->glibcLib, h.libOf("2.44")); -} + expect(fatal(pp.has_value())); + expect(eq(pp->glibcLib, h.libOf("2.44"))); +}}; // No authority is not "pick something sensible" -- it is a refusal. Guessing // here is what produced a build whose compile and run halves disagreed, and // the guess is invisible: every artifact looks normal until one links a // library built against the other version. -TEST(PayloadProbe, NoBindingIsARefusalNotAGuess) { +UTest no_binding_is_a_refusal_not_a_guess{"PayloadProbe/NoBindingIsARefusalNotAGuess", [] { TwoGlibcHome h; - EXPECT_FALSE(tc::probe_payload_paths(h.compilerBin, "").has_value()); -} + expect(!(tc::probe_payload_paths(h.compilerBin, "").has_value())); +}}; // A binding naming a version that is not installed must fail rather than fall // back to one that is. Falling back would reintroduce the split silently. -TEST(PayloadProbe, MissingVersionDoesNotFallBack) { +UTest missing_version_does_not_fall_back{"PayloadProbe/MissingVersionDoesNotFallBack", [] { TwoGlibcHome h; - EXPECT_FALSE(tc::probe_payload_paths(h.compilerBin, "glibc@2.28").has_value()); -} + expect(!(tc::probe_payload_paths(h.compilerBin, "glibc@2.28").has_value())); +}}; // The binding's NAME matters too: `musl@1.2.5` must not resolve a glibc // payload just because the version string happens to be parseable. -TEST(PayloadProbe, WrongRuntimeFamilyDoesNotResolveGlibc) { +UTest wrong_runtime_family_does_not_resolve_glibc{"PayloadProbe/WrongRuntimeFamilyDoesNotResolveGlibc", [] { TwoGlibcHome h; - EXPECT_FALSE(tc::probe_payload_paths(h.compilerBin, "musl@1.2.5").has_value()); -} + expect(!(tc::probe_payload_paths(h.compilerBin, "musl@1.2.5").has_value())); +}}; // Layout: lib64 preferred, lib accepted. Kept from the original probe -- a // payload that ships only `lib` is a real shape, and this is the one piece of // convention that stays, because it is about WHERE inside a chosen payload, // not about WHICH payload. -TEST(PayloadProbe, AcceptsLibWhenThereIsNoLib64) { +UTest accepts_lib_when_there_is_no_lib64{"PayloadProbe/AcceptsLibWhenThereIsNoLib64", [] { Tmp dir; auto xpkgs = dir.path / "data" / "xpkgs"; auto bin = xpkgs / "xim-x-gcc" / "16.1.0" / "bin" / "g++"; @@ -114,8 +116,8 @@ TEST(PayloadProbe, AcceptsLibWhenThereIsNoLib64) { touch(root / "lib" / "ld-linux-x86-64.so.2"); auto pp = tc::probe_payload_paths(bin, "glibc@2.39"); - ASSERT_TRUE(pp.has_value()); - EXPECT_EQ(pp->glibcLib, root / "lib"); -} + expect(fatal(pp.has_value())); + expect(eq(pp->glibcLib, root / "lib")); +}}; } // namespace diff --git a/tests/unit/test_toolchain_registry.cpp b/tests/unit/test_toolchain_registry.cpp index 6bfcb9dfb..07353b2ef 100644 --- a/tests/unit/test_toolchain_registry.cpp +++ b/tests/unit/test_toolchain_registry.cpp @@ -1,6 +1,7 @@ -#include import std; +import mcpplibs.mtest; +import mcpplibs.os.process; import mcpplibs.os.platform; import mcpp.toolchain.platform; import mcpp.toolchain.lifecycle; @@ -8,6 +9,8 @@ import mcpp.toolchain.model; import mcpp.toolchain.registry; import mcpp.toolchain.triple; +using namespace mcpplibs::mtest; + using namespace mcpp::toolchain; static std::string host_musl() { @@ -33,57 +36,57 @@ static std::string expected_musl_frontend(const std::string& triple) { // ── canonical two-axis identity ────────────────────────────────────────────── -TEST(ToolchainRegistry, MapsGccSpecToGccPackage) { +UTest maps_gcc_spec_to_gcc_package{"ToolchainRegistry/MapsGccSpecToGccPackage", [] { auto spec = parse_toolchain_spec("gcc@16.1.0"); - ASSERT_TRUE(spec.has_value()) << spec.error(); - EXPECT_EQ(spec->family, Family::Gcc); - EXPECT_TRUE(spec->is_host_target()); - EXPECT_EQ(spec->spec_str(), "gcc@16.1.0"); - EXPECT_EQ(spec->display(), "gcc@16.1.0"); - EXPECT_TRUE(spec->compatHint.empty()); + if (auto&& failed = expect(fatal(spec.has_value())); !failed) failed << spec.error(); + expect(eq(spec->family, Family::Gcc)); + expect(spec->is_host_target()); + expect(eq(spec->spec_str(), "gcc@16.1.0")); + expect(eq(spec->display(), "gcc@16.1.0")); + expect(spec->compatHint.empty()); auto pkg = to_xim_package(*spec); #if defined(_WIN32) // gcc family on a Windows host = MinGW-w64 (the GNU-env host toolchain). - EXPECT_EQ(pkg.ximName, "mingw-gcc"); + expect(eq(pkg.ximName, "mingw-gcc")); #else - ASSERT_FALSE(pkg.frontendCandidates.empty()); + expect(fatal(!pkg.frontendCandidates.empty())); if constexpr (mcpplibs::os::is_linux && mcpp::toolchain::platform::host_arch != std::string_view("x86_64")) { // Non-x86 Linux native GCC payloads are musl-backed even when the host // process uses glibc. Its driver needs no glibc specs fixup. - EXPECT_EQ(pkg.ximName, "musl-gcc"); - EXPECT_FALSE(pkg.needsGccPostInstallFixup); - EXPECT_EQ(pkg.frontendCandidates.front(), host_musl() + "-g++"); + expect(eq(pkg.ximName, "musl-gcc")); + expect(!pkg.needsGccPostInstallFixup); + expect(eq(pkg.frontendCandidates.front(), host_musl() + "-g++")); } else { - EXPECT_EQ(pkg.ximName, "gcc"); - EXPECT_TRUE(pkg.needsGccPostInstallFixup); - EXPECT_EQ(pkg.frontendCandidates.front(), "g++"); + expect(eq(pkg.ximName, "gcc")); + expect(bool(pkg.needsGccPostInstallFixup)); + expect(eq(pkg.frontendCandidates.front(), "g++")); } #endif - EXPECT_EQ(pkg.ximVersion, "16.1.0"); - EXPECT_EQ(pkg.display_spec(), "gcc@16.1.0"); -} + expect(eq(pkg.ximVersion, "16.1.0")); + expect(eq(pkg.display_spec(), "gcc@16.1.0")); +}}; -TEST(ToolchainRegistry, LegacyMuslSuffixNormalizesToMuslTarget) { +UTest legacy_musl_suffix_normalizes_to_musl_target{"ToolchainRegistry/LegacyMuslSuffixNormalizesToMuslTarget", [] { // "gcc@15.1.0-musl" — the variant moves out of the version and into the // target axis: (gcc, 15.1.0, -linux-musl). auto spec = parse_toolchain_spec("gcc@15.1.0-musl"); - ASSERT_TRUE(spec.has_value()) << spec.error(); - EXPECT_EQ(spec->family, Family::Gcc); - EXPECT_EQ(spec->version, "15.1.0"); - EXPECT_EQ(spec->target.str(), host_musl()); - EXPECT_FALSE(spec->compatHint.empty()); // legacy spelling → hint + if (auto&& failed = expect(fatal(spec.has_value())); !failed) failed << spec.error(); + expect(eq(spec->family, Family::Gcc)); + expect(eq(spec->version, "15.1.0")); + expect(eq(spec->target.str(), host_musl())); + expect(!(spec->compatHint.empty())); // legacy spelling → hint auto pkg = to_xim_package(*spec); - EXPECT_EQ(pkg.ximName, expected_musl_xim()); - EXPECT_EQ(pkg.ximVersion, "15.1.0"); - ASSERT_FALSE(pkg.frontendCandidates.empty()); - EXPECT_EQ(pkg.frontendCandidates.front(), expected_musl_frontend(host_musl())); - EXPECT_FALSE(pkg.needsGccPostInstallFixup); -} - -TEST(ToolchainRegistry, CrossArchMuslTargetPicksTripleNamedPackage) { + expect(eq(pkg.ximName, expected_musl_xim())); + expect(eq(pkg.ximVersion, "15.1.0")); + expect(fatal(!pkg.frontendCandidates.empty())); + expect(eq(pkg.frontendCandidates.front(), expected_musl_frontend(host_musl()))); + expect(!pkg.needsGccPostInstallFixup); +}}; + +UTest cross_arch_musl_target_picks_triple_named_package{"ToolchainRegistry/CrossArchMuslTargetPicksTripleNamedPackage", [] { // Target arch ≠ host arch → the triple-named cross package. ToolchainSpec spec; spec.family = Family::Gcc; @@ -93,113 +96,112 @@ TEST(ToolchainRegistry, CrossArchMuslTargetPicksTripleNamedPackage) { spec.target.arch = "riscv64"; // stay cross on any host auto pkg = to_xim_package(spec); - EXPECT_EQ(pkg.ximName, spec.target.str() + "-gcc"); - ASSERT_FALSE(pkg.frontendCandidates.empty()); - EXPECT_EQ(pkg.frontendCandidates.front(), expected_musl_frontend(spec.target.str())); - EXPECT_FALSE(pkg.needsGccPostInstallFixup); -} + expect(eq(pkg.ximName, spec.target.str() + "-gcc")); + expect(fatal(!pkg.frontendCandidates.empty())); + expect(eq(pkg.frontendCandidates.front(), expected_musl_frontend(spec.target.str()))); + expect(!pkg.needsGccPostInstallFixup); +}}; -TEST(ToolchainRegistry, WindowsGnuTargetIsHostSplitAtDistributionLayer) { +UTest windows_gnu_target_is_host_split_at_distribution_layer{"ToolchainRegistry/WindowsGnuTargetIsHostSplitAtDistributionLayer", [] { // ONE identity (gcc → x86_64-windows-gnu); the payload is host-split: // winlibs mingw-gcc on Windows hosts, the Linux-hosted MSVCRT cross // elsewhere. "cross" appears only in the xim package name — never in // the user-facing spec. auto spec = parse_toolchain_spec("gcc@16.1.0"); - ASSERT_TRUE(spec.has_value()); + expect(fatal(spec.has_value())); spec->target = { "x86_64", "windows", "gnu" }; auto pkg = to_xim_package(*spec); - EXPECT_EQ(pkg.ximVersion, "16.1.0"); - ASSERT_FALSE(pkg.frontendCandidates.empty()); + expect(eq(pkg.ximVersion, "16.1.0")); + expect(fatal(!pkg.frontendCandidates.empty())); #if defined(_WIN32) - EXPECT_EQ(pkg.ximName, "mingw-gcc"); - EXPECT_EQ(pkg.frontendCandidates.front(), "g++.exe"); + expect(eq(pkg.ximName, "mingw-gcc")); + expect(eq(pkg.frontendCandidates.front(), "g++.exe")); #else - EXPECT_EQ(pkg.ximName, "mingw-cross-gcc"); - EXPECT_EQ(pkg.frontendCandidates.front(), "x86_64-w64-mingw32-g++"); + expect(eq(pkg.ximName, "mingw-cross-gcc")); + expect(eq(pkg.frontendCandidates.front(), "x86_64-w64-mingw32-g++")); #endif - EXPECT_FALSE(pkg.needsGccPostInstallFixup); - EXPECT_EQ(pkg.display_spec(), "gcc@16.1.0 → x86_64-windows-gnu"); -} + expect(!pkg.needsGccPostInstallFixup); + expect(eq(pkg.display_spec(), "gcc@16.1.0 → x86_64-windows-gnu")); +}}; -TEST(ToolchainRegistry, LegacyMingwCrossSpellingCollapses) { +UTest legacy_mingw_cross_spelling_collapses{"ToolchainRegistry/LegacyMingwCrossSpellingCollapses", [] { auto spec = parse_toolchain_spec("mingw-cross@16.1.0"); - ASSERT_TRUE(spec.has_value()) << spec.error(); - EXPECT_EQ(spec->family, Family::Gcc); - EXPECT_EQ(spec->target.str(), "x86_64-windows-gnu"); - EXPECT_FALSE(spec->compatHint.empty()); -} + if (auto&& failed = expect(fatal(spec.has_value())); !failed) failed << spec.error(); + expect(eq(spec->family, Family::Gcc)); + expect(eq(spec->target.str(), "x86_64-windows-gnu")); + expect(!(spec->compatHint.empty())); +}}; -TEST(ToolchainRegistry, MapsLlvmAndClangAliasesToLlvmPackage) { +UTest maps_llvm_and_clang_aliases_to_llvm_package{"ToolchainRegistry/MapsLlvmAndClangAliasesToLlvmPackage", [] { auto llvmSpec = parse_toolchain_spec("llvm", "20.1.7"); auto clangSpec = parse_toolchain_spec("clang@20.1.7"); - ASSERT_TRUE(llvmSpec.has_value()) << llvmSpec.error(); - ASSERT_TRUE(clangSpec.has_value()) << clangSpec.error(); + if (auto&& failed = expect(fatal(llvmSpec.has_value())); !failed) failed << llvmSpec.error(); + if (auto&& failed = expect(fatal(clangSpec.has_value())); !failed) failed << clangSpec.error(); - EXPECT_EQ(llvmSpec->family, Family::Llvm); - EXPECT_EQ(clangSpec->family, Family::Llvm); // alias family → llvm - EXPECT_TRUE(llvmSpec->compatHint.empty()); - EXPECT_FALSE(clangSpec->compatHint.empty()); + expect(eq(llvmSpec->family, Family::Llvm)); + expect(eq(clangSpec->family, Family::Llvm)); // alias family → llvm + expect(llvmSpec->compatHint.empty()); + expect(!(clangSpec->compatHint.empty())); auto llvmPkg = to_xim_package(*llvmSpec); auto clangPkg = to_xim_package(*clangSpec); - EXPECT_EQ(llvmPkg.ximName, "llvm"); - EXPECT_EQ(clangPkg.ximName, "llvm"); - EXPECT_EQ(clangPkg.display_spec(), "llvm@20.1.7"); - ASSERT_FALSE(clangPkg.frontendCandidates.empty()); + expect(eq(llvmPkg.ximName, "llvm")); + expect(eq(clangPkg.ximName, "llvm")); + expect(eq(clangPkg.display_spec(), "llvm@20.1.7")); + expect(fatal(!clangPkg.frontendCandidates.empty())); #if defined(_WIN32) - EXPECT_EQ(clangPkg.frontendCandidates.front(), "clang++.exe"); + expect(eq(clangPkg.frontendCandidates.front(), "clang++.exe")); #else - EXPECT_EQ(clangPkg.frontendCandidates.front(), "clang++"); + expect(eq(clangPkg.frontendCandidates.front(), "clang++")); #endif -} +}}; -TEST(ToolchainRegistry, ResolvesPartialMuslVersion) { +UTest resolves_partial_musl_version{"ToolchainRegistry/ResolvesPartialMuslVersion", [] { auto spec = parse_toolchain_spec("gcc", "15-musl"); - ASSERT_TRUE(spec.has_value()) << spec.error(); - EXPECT_EQ(spec->version, "15"); - EXPECT_EQ(spec->target.str(), host_musl()); + if (auto&& failed = expect(fatal(spec.has_value())); !failed) failed << spec.error(); + expect(eq(spec->version, "15")); + expect(eq(spec->target.str(), host_musl())); auto resolved = with_resolved_xim_version(*spec, "15.1.0"); auto pkg = to_xim_package(resolved); - EXPECT_EQ(resolved.version, "15.1.0"); - EXPECT_EQ(pkg.ximName, expected_musl_xim()); - EXPECT_EQ(pkg.ximVersion, "15.1.0"); - EXPECT_EQ(pkg.display_spec(), - std::format("gcc@15.1.0 → {}", host_musl())); -} + expect(eq(resolved.version, "15.1.0")); + expect(eq(pkg.ximName, expected_musl_xim())); + expect(eq(pkg.ximVersion, "15.1.0")); + expect(eq(pkg.display_spec(), std::format("gcc@15.1.0 → {}", host_musl()))); +}}; -TEST(ToolchainRegistry, RejectsUnknownFamily) { +UTest rejects_unknown_family{"ToolchainRegistry/RejectsUnknownFamily", [] { auto spec = parse_toolchain_spec("tcc@1.0"); - EXPECT_FALSE(spec.has_value()); -} + expect(!spec.has_value()); +}}; // ── payload reverse mapping ────────────────────────────────────────────────── -TEST(ToolchainRegistry, IdentifiesToolchainPayloadsAndSkipsOthers) { +UTest identifies_toolchain_payloads_and_skips_others{"ToolchainRegistry/IdentifiesToolchainPayloadsAndSkipsOthers", [] { auto gcc = identify_xim_payload("gcc"); - ASSERT_TRUE(gcc.has_value()); - EXPECT_EQ(gcc->family, Family::Gcc); - EXPECT_TRUE(gcc->target.empty()); + expect(fatal(gcc.has_value())); + expect(eq(gcc->family, Family::Gcc)); + expect(gcc->target.empty()); auto musl = identify_xim_payload("musl-gcc"); - ASSERT_TRUE(musl.has_value()); - EXPECT_EQ(musl->target.str(), host_musl()); + expect(fatal(musl.has_value())); + expect(eq(musl->target.str(), host_musl())); auto crossMusl = identify_xim_payload("aarch64-linux-musl-gcc"); - ASSERT_TRUE(crossMusl.has_value()); - EXPECT_EQ(crossMusl->target.str(), "aarch64-linux-musl"); + expect(fatal(crossMusl.has_value())); + expect(eq(crossMusl->target.str(), "aarch64-linux-musl")); auto llvm = identify_xim_payload("llvm"); - ASSERT_TRUE(llvm.has_value()); - EXPECT_EQ(llvm->family, Family::Llvm); + expect(fatal(llvm.has_value())); + expect(eq(llvm->family, Family::Llvm)); // Non-toolchain xpkgs must not be identified (list/doctor filter on this). - EXPECT_FALSE(identify_xim_payload("ninja").has_value()); - EXPECT_FALSE(identify_xim_payload("glibc").has_value()); - EXPECT_FALSE(identify_xim_payload("python").has_value()); - EXPECT_FALSE(identify_xim_payload("linux-headers").has_value()); -} + expect(!(identify_xim_payload("ninja").has_value())); + expect(!(identify_xim_payload("glibc").has_value())); + expect(!(identify_xim_payload("python").has_value())); + expect(!(identify_xim_payload("linux-headers").has_value())); +}}; // #367: which GCC payload backs a NATIVE Linux build. // @@ -214,32 +216,32 @@ TEST(ToolchainRegistry, IdentifiesToolchainPayloadsAndSkipsOthers) { // Tested through a free function taking the host arch rather than the // compile-time constant, precisely so the aarch64 answer is checkable from an // x86_64 machine. -TEST(ToolchainRegistry, NativeGccPayloadFollowsWhatTheArchActuallyPublishes) { +UTest native_gcc_payload_follows_what_the_arch_actually_publishes{"ToolchainRegistry/NativeGccPayloadFollowsWhatTheArchActuallyPublishes", [] { using mcpp::toolchain::gcc_native_payload_is_musl; const mcpp::toolchain::triple::Triple none{}; // x86_64: unchanged — the glibc package is the one that exists. - EXPECT_FALSE(gcc_native_payload_is_musl("x86_64", true, none)); - EXPECT_FALSE(gcc_native_payload_is_musl( + expect(!gcc_native_payload_is_musl("x86_64", true, none)); + expect(!gcc_native_payload_is_musl( "x86_64", true, {"x86_64", "linux", "gnu"})); // aarch64 with no target: musl-gcc, the payload every version publishes. - EXPECT_TRUE(gcc_native_payload_is_musl("aarch64", true, none)); - EXPECT_TRUE(gcc_native_payload_is_musl( + expect(gcc_native_payload_is_musl("aarch64", true, none)); + expect(gcc_native_payload_is_musl( "aarch64", true, {"aarch64", "linux", "musl"})); // A target that names glibc asks for the glibc payload (xim:gcc publishes // aarch64 since 16.1.0; a Luban system's own gcc is that one). - EXPECT_FALSE(gcc_native_payload_is_musl( + expect(!gcc_native_payload_is_musl( "aarch64", true, {"aarch64", "linux", "gnu"})); // A CROSS target keeps its own payload rule — this is about the native // one, and `-gcc` packages answer for the rest. - EXPECT_FALSE(gcc_native_payload_is_musl( + expect(!gcc_native_payload_is_musl( "aarch64", true, {"x86_64", "linux", "gnu"})); // Non-Linux hosts are out of scope: macOS uses llvm, Windows mingw/msvc. - EXPECT_FALSE(gcc_native_payload_is_musl("aarch64", false, none)); -} + expect(!gcc_native_payload_is_musl("aarch64", false, none)); +}}; // ─── the origin axis ───────────────────────────────────────────────────── // @@ -261,10 +263,10 @@ TEST(ToolchainRegistry, NativeGccPayloadFollowsWhatTheArchActuallyPublishes) { // A CONSTANT HERE WOULD BE THE DEFECT THIS AVOIDS: this repository has // recorded more than once that a version written into a comment becomes a // version in a diagnostic and then in somebody's install command. -TEST(ToolchainRegistry, TheNdkApiLevelFloorIsReadFromThePayloadsOwnMetadata) { +UTest the_ndk_api_level_floor_is_read_from_the_payloads_own_metadata{"ToolchainRegistry/TheNdkApiLevelFloorIsReadFromThePayloadsOwnMetadata", [] { namespace fs = std::filesystem; auto root = fs::temp_directory_path() - / ("mcpp-ndk-meta-" + std::to_string(::getpid())); + / ("mcpp-ndk-meta-" + std::to_string(mcpplibs::os::process::self()->id)); fs::remove_all(root); // The real layout: the compiler sits four directories below the NDK root, // and `meta/` is a sibling of `toolchains/`. @@ -279,7 +281,7 @@ TEST(ToolchainRegistry, TheNdkApiLevelFloorIsReadFromThePayloadsOwnMetadata) { std::ofstream o(root / "meta" / "platforms.json"); o << R"({"min": 21, "max": 37, "aliases": {"N": 24}})"; } - EXPECT_EQ(mcpp::toolchain::ndk_min_api_level(clangxx), 21); + expect(eq(mcpp::toolchain::ndk_min_api_level(clangxx), 21)); // A DIFFERENT PAYLOAD ANSWERS DIFFERENTLY, which is the whole point of // reading it: the same code must not return 21 for an NDK that says 24. @@ -287,68 +289,66 @@ TEST(ToolchainRegistry, TheNdkApiLevelFloorIsReadFromThePayloadsOwnMetadata) { std::ofstream o(root / "meta" / "platforms.json"); o << R"({"min": 24, "max": 40})"; } - EXPECT_EQ(mcpp::toolchain::ndk_min_api_level(clangxx), 24); + expect(eq(mcpp::toolchain::ndk_min_api_level(clangxx), 24)); // 0 WHEN IT CANNOT BE READ, and the caller turns that into a refusal // naming `min_api_level`. A guessed level would be worse than the refusal: // it selects which bionic symbols exist, so guessing produces an artefact // that links here and fails to load on a device. fs::remove(root / "meta" / "platforms.json"); - EXPECT_EQ(mcpp::toolchain::ndk_min_api_level(clangxx), 0); + expect(eq(mcpp::toolchain::ndk_min_api_level(clangxx), 0)); // Malformed rather than absent -- same answer, and no exception escapes. { std::ofstream o(root / "meta" / "platforms.json"); o << "{not json"; } - EXPECT_EQ(mcpp::toolchain::ndk_min_api_level(clangxx), 0); + expect(eq(mcpp::toolchain::ndk_min_api_level(clangxx), 0)); // Present but not a number: still 0, never a silent 1 from a cast. { std::ofstream o(root / "meta" / "platforms.json"); o << R"({"min": "21"})"; } - EXPECT_EQ(mcpp::toolchain::ndk_min_api_level(clangxx), 0); + expect(eq(mcpp::toolchain::ndk_min_api_level(clangxx), 0)); // A path that is not inside an NDK at all walks to the filesystem root and // stops; it must not loop. - EXPECT_EQ(mcpp::toolchain::ndk_min_api_level( - fs::temp_directory_path() / "definitely-not-an-ndk" / "clang++"), 0); + expect(eq(mcpp::toolchain::ndk_min_api_level( + fs::temp_directory_path() / "definitely-not-an-ndk" / "clang++"), 0)); fs::remove_all(root); -} +}}; -TEST(ToolchainOrigin, MsvcIsTheOnlyFamilyWithASystemSpelling) { +UTest msvc_is_the_only_family_with_a_system_spelling{"ToolchainOrigin/MsvcIsTheOnlyFamilyWithASystemSpelling", [] { auto msvcSystem = parse_toolchain_spec("msvc@system"); - ASSERT_TRUE(msvcSystem.has_value()) << msvcSystem.error(); - EXPECT_TRUE(is_system_toolchain(*msvcSystem)); - EXPECT_EQ(origin_of(*msvcSystem), Origin::SystemMsvc); + if (auto&& failed = expect(fatal(msvcSystem.has_value())); !failed) failed << msvcSystem.error(); + expect(is_system_toolchain(*msvcSystem)); + expect(eq(origin_of(*msvcSystem), Origin::SystemMsvc)); // A VERSIONED msvc spec is the other origin — that split is the whole // point of the version axis. auto pinned = parse_toolchain_spec("msvc@14.44.35207"); - ASSERT_TRUE(pinned.has_value()); - EXPECT_EQ(origin_of(*pinned), Origin::Managed); + expect(fatal(pinned.has_value())); + expect(eq(origin_of(*pinned), Origin::Managed)); auto gcc = parse_toolchain_spec("gcc@16.1.0"); - ASSERT_TRUE(gcc.has_value()); - EXPECT_EQ(origin_of(*gcc), Origin::Managed); -} + expect(fatal(gcc.has_value())); + expect(eq(origin_of(*gcc), Origin::Managed)); +}}; -TEST(ToolchainOrigin, NonMsvcSystemIsRejectedWhereItIsReadAndOffersTheAlternatives) { +UTest non_msvc_system_is_rejected_where_it_is_read_and_offers_the_alternatives{"ToolchainOrigin/NonMsvcSystemIsRejectedWhereItIsReadAndOffersTheAlternatives", [] { // It used to parse, and then fail somewhere else entirely as // `xim:gcc@system` → "no such package" — which sends the reader looking // for a version that was never going to exist. The error has to name both // things the user might have meant. for (auto spec : {"gcc@system", "llvm@system"}) { auto r = parse_toolchain_spec(spec); - ASSERT_FALSE(r.has_value()) << spec << " was accepted"; - EXPECT_NE(r.error().find("only msvc"), std::string::npos) << r.error(); - EXPECT_NE(r.error().find("@"), std::string::npos) - << "the pin alternative is not offered: " << r.error(); + expect(fatal(!r.has_value())) << spec << " was accepted"; + if (auto&& failed = expect(ne(r.error().find("only msvc"), std::string::npos)); !failed) failed << r.error(); + if (auto&& failed = expect(ne(r.error().find("@"), std::string::npos)); !failed) failed << "the pin alternative is not offered: " << r.error(); // The family-less PATH escape hatch is the OTHER thing they might // have wanted, and it is a different mechanism. - EXPECT_NE(r.error().find("PATH compiler"), std::string::npos) + expect(ne(r.error().find("PATH compiler"), std::string::npos)) << "the escape hatch is not offered: " << r.error(); - EXPECT_EQ(r.error().find("xim:"), std::string::npos) - << "still leaking the package spelling that cannot exist: " + if (auto&& failed = expect(eq(r.error().find("xim:"), std::string::npos)); !failed) failed << "still leaking the package spelling that cannot exist: " << r.error(); } -} +}}; // ─── An SDK payload is chosen by the TARGET, and knows its own layout ────── // @@ -357,7 +357,7 @@ TEST(ToolchainOrigin, NonMsvcSystemIsRejectedWhereItIsReadAndOffersTheAlternativ // that arrive with their own clang. `em++` and the NDK's `clang++` ARE clang -- // same family, same flag vocabulary -- so no fourth `Family` value exists; // what changes is which package answers and where its driver lives. -TEST(SdkPayloads, TheTargetChoosesThePackageAndThePackageKnowsItsLayout) { +UTest the_target_chooses_the_package_and_the_package_knows_its_layout{"SdkPayloads/TheTargetChoosesThePackageAndThePackageKnowsItsLayout", [] { auto pkg_for = [](std::string_view target) { // A SPELLING THAT NAMES NO PAYLOAD, WHICH IS WHAT THIS TEST IS ABOUT. // @@ -382,34 +382,32 @@ TEST(SdkPayloads, TheTargetChoosesThePackageAndThePackageKnowsItsLayout) { // compiles for wasm and then links like an ordinary clang, producing a // module with none of Emscripten's JavaScript glue. auto pkg = pkg_for("wasm32-emscripten"); - EXPECT_EQ(pkg.ximName, "emsdk"); - EXPECT_EQ(pkg.frontendSubdir, "emscripten"); - ASSERT_FALSE(pkg.frontendCandidates.empty()); - EXPECT_EQ(pkg.frontendCandidates.front(), "em++"); + expect(eq(pkg.ximName, "emsdk")); + expect(eq(pkg.frontendSubdir, "emscripten")); + expect(fatal(!pkg.frontendCandidates.empty())); + expect(eq(pkg.frontendCandidates.front(), "em++")); } { // Android: ONE payload for both arches -- the arch arrives as // `--target=-linux-android`, not as a different package -- // and the host tuple in the path is the HOST's, not the target's. for (auto target : {"aarch64-linux-android", "x86_64-linux-android"}) { auto pkg = pkg_for(target); - EXPECT_EQ(pkg.ximName, "android-ndk") << target; - EXPECT_NE(pkg.frontendSubdir.find("toolchains/llvm/prebuilt/"), - std::string::npos) << target; + expect(eq(pkg.ximName, "android-ndk")) << target; + expect(ne(pkg.frontendSubdir.find("toolchains/llvm/prebuilt/"), std::string::npos)) << target; // The HOST, so an aarch64 Linux machine cross-compiling still // reads `linux-x86_64`. Asserted as "not the target's arch" rather // than against a literal, so this test says the same thing on // every runner. - EXPECT_EQ(pkg.frontendSubdir.find("aarch64-linux-android"), - std::string::npos) << target; + expect(eq(pkg.frontendSubdir.find("aarch64-linux-android"), std::string::npos)) << target; } } { // And every other target still gets the generic llvm payload in bin/. auto pkg = pkg_for("x86_64-linux-gnu"); - EXPECT_NE(pkg.ximName, "emsdk"); - EXPECT_NE(pkg.ximName, "android-ndk"); - EXPECT_EQ(pkg.frontendSubdir, "bin"); + expect(ne(pkg.ximName, "emsdk")); + expect(ne(pkg.ximName, "android-ndk")); + expect(eq(pkg.frontendSubdir, "bin")); } -} +}}; // THE MESSAGE MUST NAME THE DIRECTORY THAT WAS SEARCHED. // @@ -417,19 +415,19 @@ TEST(SdkPayloads, TheTargetChoosesThePackageAndThePackageKnowsItsLayout) { // themselves. Once the package decides where its frontend lives, `bin` is a // directory nothing looked in -- and this codebase's most frequent defect is a // fixed lookup with an unfixed message. -TEST(SdkPayloads, TheSearchedDirectoryIsAvailableForTheDiagnostic) { +UTest the_searched_directory_is_available_for_the_diagnostic{"SdkPayloads/TheSearchedDirectoryIsAvailableForTheDiagnostic", [] { auto spec = mcpp::toolchain::parse_toolchain_spec("emsdk@6.0.9"); - ASSERT_TRUE(spec.has_value()); + expect(fatal(spec.has_value())); auto pkg = mcpp::toolchain::to_xim_package(*spec); auto dir = mcpp::toolchain::payload_frontend_dir("/p/xim-x-emsdk/6.0.9", pkg); - EXPECT_EQ(dir, std::filesystem::path("/p/xim-x-emsdk/6.0.9/emscripten")); -} + expect(eq(dir, std::filesystem::path("/p/xim-x-emsdk/6.0.9/emscripten"))); +}}; // AN SDK IS SERVED WHERE THE SDK IS PUBLISHED, and the first version of this // said "wherever" -- the same over-broad shape as the branches it sits above. // The target matrix caught it: declaring the row servable on macOS and Windows // would have claimed a payload that does not exist there. -TEST(SdkPayloads, ServedOnEveryHostTheSdkIsPublishedFor) { +UTest served_on_every_host_the_sdk_is_published_for{"SdkPayloads/ServedOnEveryHostTheSdkIsPublishedFor", [] { // THIS ASSERTION USED TO BE TRUE BY ARITHMETIC ON ONE HOST. // // It read `EXPECT_EQ(host_can_serve(*wasm), mcpplibs::os::is_linux)`, @@ -444,8 +442,8 @@ TEST(SdkPayloads, ServedOnEveryHostTheSdkIsPublishedFor) { for (auto name : {"wasm32-emscripten", "aarch64-linux-android", "x86_64-linux-android"}) { auto t = mcpp::toolchain::triple::parse(name); - ASSERT_TRUE(t.has_value()) << name; - EXPECT_TRUE(mcpp::toolchain::host_can_serve(*t)) << name; + expect(fatal(t.has_value())) << name; + expect(mcpp::toolchain::host_can_serve(*t)) << name; } // AND THE PREDICATE IS STILL ABLE TO SAY NO, which is what keeps the @@ -454,8 +452,8 @@ TEST(SdkPayloads, ServedOnEveryHostTheSdkIsPublishedFor) { // serve them -- the exclusion this function exists to make. if constexpr (mcpplibs::os::is_linux) { auto mac = mcpp::toolchain::triple::parse("aarch64-macos"); - ASSERT_TRUE(mac.has_value()); - EXPECT_FALSE(mcpp::toolchain::host_can_serve(*mac)); + expect(fatal(mac.has_value())); + expect(!mcpp::toolchain::host_can_serve(*mac)); } // AND APPLE IS ONE ANSWER FOR BOTH OF ITS PLATFORMS, which this function @@ -476,12 +474,11 @@ TEST(SdkPayloads, ServedOnEveryHostTheSdkIsPublishedFor) { for (auto name : {"aarch64-macos", "aarch64-ios", "aarch64-ios-sim", "x86_64-ios-sim"}) { auto t = mcpp::toolchain::triple::parse(name); - ASSERT_TRUE(t.has_value()) << name; - EXPECT_TRUE(t->is_apple()) << name; - EXPECT_EQ(mcpp::toolchain::host_can_serve(*t), - bool(mcpplibs::os::is_macos)) << name; + expect(fatal(t.has_value())) << name; + expect(t->is_apple()) << name; + expect(eq(mcpp::toolchain::host_can_serve(*t), bool(mcpplibs::os::is_macos))) << name; } -} +}}; // ─── The payload is SAID, not only resolved (R3) ─────────────────────────── // @@ -491,33 +488,31 @@ TEST(SdkPayloads, ServedOnEveryHostTheSdkIsPublishedFor) { // `xim:llvm`, and not what the user typed. The family and the payload are two // questions, and `to_xim_package` already answered the second; this is the // field that lets it be printed. -TEST(SdkPayloads, TheDisplayNamesThePayloadAndNotOnlyTheFamily) { +UTest the_display_names_the_payload_and_not_only_the_family{"SdkPayloads/TheDisplayNamesThePayloadAndNotOnlyTheFamily", [] { auto em = mcpp::toolchain::parse_toolchain_spec("emsdk@6.0.9"); - ASSERT_TRUE(em.has_value()); - EXPECT_EQ(em->family, mcpp::toolchain::Family::Llvm) + expect(fatal(em.has_value())); + expect(eq(em->family, mcpp::toolchain::Family::Llvm)) << "em++ is clang; a fourth family would be a false claim"; - EXPECT_EQ(em->payloadName, "emsdk"); - EXPECT_NE(em->display().find("emsdk@6.0.9"), std::string::npos) - << em->display(); - EXPECT_EQ(em->display().find("llvm@"), std::string::npos) << em->display(); + expect(eq(em->payloadName, "emsdk")); + if (auto&& failed = expect(ne(em->display().find("emsdk@6.0.9"), std::string::npos)); !failed) failed << em->display(); + if (auto&& failed = expect(eq(em->display().find("llvm@"), std::string::npos)); !failed) failed << em->display(); auto ndk = mcpp::toolchain::parse_toolchain_spec("android-ndk@30.0.16248370"); - ASSERT_TRUE(ndk.has_value()); - EXPECT_EQ(ndk->family, mcpp::toolchain::Family::Llvm); - EXPECT_EQ(ndk->payloadName, "android-ndk"); - EXPECT_NE(ndk->display().find("android-ndk@"), std::string::npos) - << ndk->display(); + expect(fatal(ndk.has_value())); + expect(eq(ndk->family, mcpp::toolchain::Family::Llvm)); + expect(eq(ndk->payloadName, "android-ndk")); + if (auto&& failed = expect(ne(ndk->display().find("android-ndk@"), std::string::npos)); !failed) failed << ndk->display(); // AND NOTHING ELSE MOVES. Empty `payloadName` means the family's own // payload, which is every row but these two -- so no existing output // changes, which is what makes this additive. for (auto spelled : {"llvm@22.1.8", "gcc@16.1.0", "msvc@14.44.35207"}) { auto sp = mcpp::toolchain::parse_toolchain_spec(spelled); - ASSERT_TRUE(sp.has_value()) << spelled; - EXPECT_TRUE(sp->payloadName.empty()) << spelled; - EXPECT_NE(sp->display().find(spelled), std::string::npos) << sp->display(); + expect(fatal(sp.has_value())) << spelled; + expect(sp->payloadName.empty()) << spelled; + if (auto&& failed = expect(ne(sp->display().find(spelled), std::string::npos)); !failed) failed << sp->display(); } -} +}}; // ─── A spec that names a payload has answered which payload ─────────────── // @@ -538,7 +533,7 @@ TEST(SdkPayloads, TheDisplayNamesThePayloadAndNotOnlyTheFamily) { // THE DENOMINATOR IS BOTH PAYLOADS AND BOTH SPELLINGS, because the defect is // in the AXIS and not in one row: a payload named with a target, and the same // payload named without one, must answer identically. -TEST(SdkPayloads, ThePayloadTheSpecNamedDecidesWithoutATarget) { +UTest the_payload_the_spec_named_decides_without_a_target{"SdkPayloads/ThePayloadTheSpecNamedDecidesWithoutATarget", [] { struct Case { std::string_view spelling, ximName, subdir, frontend, target; }; @@ -552,14 +547,14 @@ TEST(SdkPayloads, ThePayloadTheSpecNamedDecidesWithoutATarget) { }; for (auto const& c : cases) { auto spec = mcpp::toolchain::parse_toolchain_spec(std::string(c.spelling)); - ASSERT_TRUE(spec.has_value()) << c.spelling; + expect(fatal(spec.has_value())) << c.spelling; if (!c.target.empty()) if (auto t = mcpp::toolchain::triple::parse(c.target)) spec->target = *t; auto pkg = mcpp::toolchain::to_xim_package(*spec); const std::string where = std::string(c.spelling) + " target='" + std::string(c.target) + "'"; - EXPECT_EQ(pkg.ximName, c.ximName) << where; - EXPECT_NE(pkg.frontendSubdir.find(c.subdir), std::string::npos) + expect(eq(pkg.ximName, c.ximName)) << where; + expect(ne(pkg.frontendSubdir.find(c.subdir), std::string::npos)) << where << " subdir=" << pkg.frontendSubdir; // The candidate list names the payload's own driver rather than the // family's, which is the half that sent the install looking for @@ -567,7 +562,7 @@ TEST(SdkPayloads, ThePayloadTheSpecNamedDecidesWithoutATarget) { bool named = false; for (auto const& cand : pkg.frontendCandidates) if (cand.find(c.frontend) != std::string::npos) named = true; - EXPECT_TRUE(named) << where << " candidates do not name " << c.frontend; + expect(bool(named)) << where << " candidates do not name " << c.frontend; } // AND A SPEC THAT NAMES NO PAYLOAD STILL READS THE TARGET. This is the @@ -576,11 +571,11 @@ TEST(SdkPayloads, ThePayloadTheSpecNamedDecidesWithoutATarget) { // rather than the NDK -- the capability gate is what refuses it, and a // gate cannot refuse what was silently rewritten. auto plain = mcpp::toolchain::parse_toolchain_spec("llvm@22.1.8"); - ASSERT_TRUE(plain.has_value()); - EXPECT_TRUE(plain->payloadName.empty()); + expect(fatal(plain.has_value())); + expect(plain->payloadName.empty()); if (auto t = mcpp::toolchain::triple::parse("aarch64-macos")) plain->target = *t; - EXPECT_EQ(mcpp::toolchain::to_xim_package(*plain).ximName, "llvm"); -} + expect(eq(mcpp::toolchain::to_xim_package(*plain).ximName, "llvm")); +}}; // ─── The C compiler beside a C++ one ─────────────────────────────────────── // @@ -599,7 +594,7 @@ TEST(SdkPayloads, ThePayloadTheSpecNamedDecidesWithoutATarget) { // clang's rule. `g` was already a special case for exactly this reason, which // is what makes a table the right shape: the property is "this driver names // its C compiler with a different word". -TEST(SdkPayloads, EveryFrontendNamesTheCCompilerBesideIt) { +UTest every_frontend_names_the_c_compiler_beside_it{"SdkPayloads/EveryFrontendNamesTheCCompilerBesideIt", [] { struct Case { std::string_view cxx, c; }; const Case cases[] = { // clang and its triple-prefixed forms: dropping `++` is correct. @@ -618,24 +613,23 @@ TEST(SdkPayloads, EveryFrontendNamesTheCCompilerBesideIt) { for (auto const& c : cases) { const std::filesystem::path cxx = std::filesystem::path("/p/bin") / std::string(c.cxx); - EXPECT_EQ(mcpp::toolchain::derive_c_compiler_path(cxx).filename().string(), - std::string(c.c)) << c.cxx; + expect(eq(mcpp::toolchain::derive_c_compiler_path(cxx).filename().string(), std::string(c.c))) << c.cxx; } // AND THE EXECUTABLE SUFFIX SURVIVES, which is the part a stem-based // rewrite loses: on Windows the payload ships `g++.exe`, and a C compile // spawning `gcc` with no suffix finds nothing. - EXPECT_EQ(mcpp::toolchain::derive_c_compiler_path("/p/bin/g++.exe") - .filename().string(), "gcc.exe"); - EXPECT_EQ(mcpp::toolchain::derive_c_compiler_path("/p/bin/clang++.exe") - .filename().string(), "clang.exe"); + expect(eq(mcpp::toolchain::derive_c_compiler_path("/p/bin/g++.exe") + .filename().string(), "gcc.exe")); + expect(eq(mcpp::toolchain::derive_c_compiler_path("/p/bin/clang++.exe") + .filename().string(), "clang.exe")); // `clang++` MUST NOT MATCH THE `g++` ROW. It ends in `g++`'s last two // characters plus nothing, and a suffix test without the separator would // turn it into `clanccc` or worse -- a name that is nothing. - EXPECT_EQ(mcpp::toolchain::derive_c_compiler_path("/p/bin/clang++") - .filename().string(), "clang"); -} + expect(eq(mcpp::toolchain::derive_c_compiler_path("/p/bin/clang++") + .filename().string(), "clang")); +}}; // ─── One payload, one spelling (B1) ──────────────────────────────────────── // @@ -652,25 +646,23 @@ TEST(SdkPayloads, EveryFrontendNamesTheCCompilerBesideIt) { // the one the index uses, so a single string names this payload ecosystem-wide. // // The criterion is WHERE the refusal happens, not that one happens. -TEST(SdkPayloads, TheWithdrawnAliasIsRefusedAtParseAndNotByTheCapabilityGate) { +UTest the_withdrawn_alias_is_refused_at_parse_and_not_by_the_capability_gate{"SdkPayloads/TheWithdrawnAliasIsRefusedAtParseAndNotByTheCapabilityGate", [] { auto aliased = mcpp::toolchain::parse_toolchain_spec("ndk@30.0.16248370"); - ASSERT_FALSE(aliased.has_value()) + expect(fatal(!aliased.has_value())) << "an alias that parses is an alias the capability gate must refuse " "later, by comparing spellings"; - EXPECT_NE(aliased.error().find("unknown toolchain"), std::string::npos) - << aliased.error(); - EXPECT_NE(aliased.error().find("'ndk'"), std::string::npos) - << "the refusal quotes what was typed: " << aliased.error(); + if (auto&& failed = expect(ne(aliased.error().find("unknown toolchain"), std::string::npos)); !failed) failed << aliased.error(); + if (auto&& failed = expect(ne(aliased.error().find("'ndk'"), std::string::npos)); !failed) failed << "the refusal quotes what was typed: " << aliased.error(); // AND IT SAYS WHICH SPELLING TO USE. A refusal that names the accepted // payload is one edit away from a build; one that does not sends the // reader to the documentation. - EXPECT_NE(aliased.error().find("android-ndk"), std::string::npos) + expect(ne(aliased.error().find("android-ndk"), std::string::npos)) << aliased.error(); // The kept spelling is unaffected, and it is the one the row pins. auto kept = mcpp::toolchain::parse_toolchain_spec("android-ndk@30.0.16248370"); - ASSERT_TRUE(kept.has_value()) << kept.error(); - EXPECT_EQ(kept->payloadName, "android-ndk"); + if (auto&& failed = expect(fatal(kept.has_value())); !failed) failed << kept.error(); + expect(eq(kept->payloadName, "android-ndk")); // THE MECHANISM THAT NO LONGER HAS TO BE TAUGHT. Both Android rows pin the // kept spelling, and the gate's test is `pin's name is a substring of what @@ -678,17 +670,16 @@ TEST(SdkPayloads, TheWithdrawnAliasIsRefusedAtParseAndNotByTheCapabilityGate) { // the alias gone, the gate never sees the case it would have to special. for (auto triple : {"aarch64-linux-android", "x86_64-linux-android"}) { auto parsed = mcpp::toolchain::triple::parse(triple); - ASSERT_TRUE(parsed.has_value()) << triple; + expect(fatal(parsed.has_value())) << triple; auto info = mcpp::toolchain::triple::find_known_target(*parsed); - ASSERT_NE(info, nullptr) << triple; + expect(fatal(ne(info, nullptr))) << triple; const std::string pin(info->pin); const std::string pinName = pin.substr(0, pin.find('@')); - EXPECT_EQ(pinName, "android-ndk") << triple << " pins " << pin; - EXPECT_EQ(std::string("ndk@30.0.16248370").find(pinName), - std::string::npos) + expect(eq(pinName, "android-ndk")) << triple << " pins " << pin; + expect(eq(std::string("ndk@30.0.16248370").find(pinName), std::string::npos)) << "the alias could only pass the gate by a second mechanism"; } -} +}}; // ── A required family's version comes from pins of the same payload (#641) ── // @@ -718,21 +709,21 @@ std::string pinned_row_version(std::string_view payload) { } } // namespace -TEST(RequiredFamilyPins, EachFamilyTakesThePinsOfItsOwnPayload) { +UTest each_family_takes_the_pins_of_its_own_payload{"RequiredFamilyPins/EachFamilyTakesThePinsOfItsOwnPayload", [] { for (auto payload : {"llvm", "emsdk", "android-ndk", "gcc"}) { const auto expected = pinned_row_version(payload); - ASSERT_FALSE(expected.empty()) << "no row pins " << payload; - EXPECT_EQ(highest_pin(payload), expected) << payload; + expect(fatal(!expected.empty())) << "no row pins " << payload; + expect(eq(highest_pin(payload), expected)) << payload; } -} +}}; -TEST(RequiredFamilyPins, NoLlvmPinComesFromAnotherPayload) { +UTest no_llvm_pin_comes_from_another_payload{"RequiredFamilyPins/NoLlvmPinComesFromAnotherPayload", [] { auto spec = parse_toolchain_spec("llvm"); - ASSERT_TRUE(spec.has_value()) << spec.error(); + if (auto&& failed = expect(fatal(spec.has_value())); !failed) failed << spec.error(); const auto versions = pinned_versions_for(*spec); - EXPECT_FALSE(versions.empty()); + expect(!versions.empty()); for (auto const& v : versions) { - EXPECT_NE(v, pinned_row_version("android-ndk")); - EXPECT_NE(v, pinned_row_version("emsdk")); + expect(ne(v, pinned_row_version("android-ndk"))); + expect(ne(v, pinned_row_version("emsdk"))); } -} +}}; diff --git a/tests/unit/test_toolchain_stdmod.cpp b/tests/unit/test_toolchain_stdmod.cpp index f25b7edcb..cee77ac43 100644 --- a/tests/unit/test_toolchain_stdmod.cpp +++ b/tests/unit/test_toolchain_stdmod.cpp @@ -1,10 +1,12 @@ -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.clang; import mcpp.toolchain.gcc; import mcpp.toolchain.model; +using namespace mcpplibs::mtest; + using namespace mcpp::toolchain; namespace { @@ -36,26 +38,26 @@ Toolchain clang_toolchain() { } // namespace -TEST(ToolchainStdmod, GccStdModuleCommandUsesRequestedStandard) { +UTest gcc_std_module_command_uses_requested_standard{"ToolchainStdmod/GccStdModuleCommandUsesRequestedStandard", [] { auto cmd = gcc::std_module_build_command( gcc_toolchain(), "cache", "", "-std=c++26"); - EXPECT_NE(cmd.find("-std=c++26"), std::string::npos) << cmd; - EXPECT_EQ(cmd.find("-std=c++23"), std::string::npos) << cmd; -} + expect(ne(cmd.find("-std=c++26"), std::string::npos)) << cmd; + expect(eq(cmd.find("-std=c++23"), std::string::npos)) << cmd; +}}; -TEST(ToolchainStdmod, ClangStdModuleCommandsUseRequestedStandard) { +UTest clang_std_module_commands_use_requested_standard{"ToolchainStdmod/ClangStdModuleCommandsUseRequestedStandard", [] { auto cmds = clang::std_module_build_commands( clang_toolchain(), "cache", "cache/pcm.cache/std.pcm", "", "-std=c++26"); - ASSERT_EQ(cmds.size(), 2u); + expect(fatal(eq(cmds.size(), 2u))); for (auto const& cmd : cmds) { - EXPECT_NE(cmd.find("-std=c++26"), std::string::npos) << cmd; - EXPECT_EQ(cmd.find("-std=c++23"), std::string::npos) << cmd; + expect(ne(cmd.find("-std=c++26"), std::string::npos)) << cmd; + expect(eq(cmd.find("-std=c++23"), std::string::npos)) << cmd; } -} +}}; -TEST(ToolchainStdmod, ClangStdCompatCommandsUseRequestedStandard) { +UTest clang_std_compat_commands_use_requested_standard{"ToolchainStdmod/ClangStdCompatCommandsUseRequestedStandard", [] { auto cmds = clang::std_compat_build_commands( clang_toolchain(), "cache", @@ -64,33 +66,33 @@ TEST(ToolchainStdmod, ClangStdCompatCommandsUseRequestedStandard) { "", "-std=c++26"); - ASSERT_EQ(cmds.size(), 2u); + expect(fatal(eq(cmds.size(), 2u))); for (auto const& cmd : cmds) { - EXPECT_NE(cmd.find("-std=c++26"), std::string::npos) << cmd; - EXPECT_EQ(cmd.find("-std=c++23"), std::string::npos) << cmd; + expect(ne(cmd.find("-std=c++26"), std::string::npos)) << cmd; + expect(eq(cmd.find("-std=c++23"), std::string::npos)) << cmd; } -} +}}; -TEST(ToolchainStdmod, MsvcCompatSourceIsParsedAsAModuleButItsBmiIsNot) { +UTest msvc_compat_source_is_parsed_as_a_module_but_its_bmi_is_not{"ToolchainStdmod/MsvcCompatSourceIsParsedAsAModuleButItsBmiIsNot", [] { auto tc = clang_toolchain(); tc.stdModuleSource = "std.ixx"; tc.stdCompatSource = "std.compat.ixx"; auto cmds = clang::std_compat_build_commands( tc, "cache", "cache/pcm.cache/std.compat.pcm", "cache/pcm.cache/std.pcm", "", "-std=c++23", "-fms-runtime-lib=dll"); - ASSERT_EQ(cmds.size(), 2u); - EXPECT_NE(cmds[0].find("-x c++-module"), std::string::npos); - EXPECT_EQ(cmds[1].find("-x c++-module"), std::string::npos); + expect(fatal(eq(cmds.size(), 2u))); + expect(ne(cmds[0].find("-x c++-module"), std::string::npos)); + expect(eq(cmds[1].find("-x c++-module"), std::string::npos)); for (auto const& cmd : cmds) { - EXPECT_NE(cmd.find("-fmodule-file=std="), std::string::npos); - EXPECT_NE(cmd.find("-fms-runtime-lib=dll"), std::string::npos); + expect(ne(cmd.find("-fmodule-file=std="), std::string::npos)); + expect(ne(cmd.find("-fms-runtime-lib=dll"), std::string::npos)); } tc.stdCompatSource = "std.compat.cppm"; cmds = clang::std_compat_build_commands( tc, "cache", "cache/pcm.cache/std.compat.pcm", "cache/pcm.cache/std.pcm", "", "-std=c++23"); - EXPECT_EQ(cmds[0].find("-x c++-module"), std::string::npos); -} + expect(eq(cmds[0].find("-x c++-module"), std::string::npos)); +}}; // THE PRECOMPILE HAS TO KNOW WHICH MACHINE, AND ONLY ONE OF TWO SOURCES EVER // CARRIES IT. @@ -106,36 +108,35 @@ TEST(ToolchainStdmod, MsvcCompatSourceIsParsedAsAModuleButItsBmiIsNot) { // carries the target inside `stdModuleFlags`. A payload whose compiler serves // SEVERAL targets is a third kind and has neither -- one NDK clang++ compiles // for both Android ABIs and is told which by `--target` alone. -TEST(ToolchainStdmod, ThePrecompileCarriesTheMachineWhenOnlyTargetFlagsHaveIt) { +UTest the_precompile_carries_the_machine_when_only_target_flags_have_it{"ToolchainStdmod/ThePrecompileCarriesTheMachineWhenOnlyTargetFlagsHaveIt", [] { auto tc = clang_toolchain(); tc.targetTriple = "aarch64-linux-android"; // What prepare_build sets for such a row: the machine, and nothing about // include paths, because the SDK's own driver finds those. tc.stdModuleTargetFlags = " --target=aarch64-unknown-linux-android21 -D__BIONIC_CTYPE_INLINE="; - ASSERT_TRUE(tc.stdModuleFlags.empty()); + expect(fatal(tc.stdModuleFlags.empty())); auto cmds = clang::std_module_build_commands( tc, "cache", "cache/pcm.cache/std.pcm", "", "-std=c++23"); - ASSERT_EQ(cmds.size(), 2u); + expect(fatal(eq(cmds.size(), 2u))); // BOTH commands, not just the second. The precompile is the one that was // missing it and the one whose failure names a header. for (auto const& cmd : cmds) { - EXPECT_NE(cmd.find("--target=aarch64-unknown-linux-android21"), - std::string::npos) << cmd; + expect(ne(cmd.find("--target=aarch64-unknown-linux-android21"), std::string::npos)) << cmd; } // And the bionic workaround reaches the step that parses the headers. - EXPECT_NE(cmds[0].find("-D__BIONIC_CTYPE_INLINE="), std::string::npos) + expect(ne(cmds[0].find("-D__BIONIC_CTYPE_INLINE="), std::string::npos)) << cmds[0]; -} +}}; // AND IT IS NOT ADDED TWICE. `stdModuleFlags` is a SUPERSET of // `stdModuleTargetFlags` when it is set at all -- its producer builds the // machine part first and appends the include part -- so a precompile that // concatenated both would put `--target=` on the command line twice. Taking // the superset in preference is what keeps that from happening. -TEST(ToolchainStdmod, APackageProvidedModuleStillStatesTheMachineExactlyOnce) { +UTest a_package_provided_module_still_states_the_machine_exactly_once{"ToolchainStdmod/APackageProvidedModuleStillStatesTheMachineExactlyOnce", [] { auto tc = clang_toolchain(); tc.targetTriple = "aarch64-macos"; tc.stdModuleTargetFlags = " --target=arm64-apple-macos14.0"; @@ -144,7 +145,7 @@ TEST(ToolchainStdmod, APackageProvidedModuleStillStatesTheMachineExactlyOnce) { auto cmds = clang::std_module_build_commands( tc, "cache", "cache/pcm.cache/std.pcm", "", "-std=c++23"); - ASSERT_EQ(cmds.size(), 2u); + expect(fatal(eq(cmds.size(), 2u))); const auto count = [](std::string_view hay, std::string_view needle) { std::size_t n = 0, at = 0; @@ -153,10 +154,9 @@ TEST(ToolchainStdmod, APackageProvidedModuleStillStatesTheMachineExactlyOnce) { } return n; }; - EXPECT_EQ(count(cmds[0], "--target="), 1u) << cmds[0]; + if (auto&& failed = expect(eq(count(cmds[0], "--target="), 1u)); !failed) failed << cmds[0]; // The package's include path reaches the step that needs it, and only it. - EXPECT_NE(cmds[0].find("-isystem /pkg/include"), std::string::npos) + expect(ne(cmds[0].find("-isystem /pkg/include"), std::string::npos)) << cmds[0]; - EXPECT_EQ(cmds[1].find("-isystem /pkg/include"), std::string::npos) - << cmds[1]; -} + if (auto&& failed = expect(eq(cmds[1].find("-isystem /pkg/include"), std::string::npos)); !failed) failed << cmds[1]; +}}; diff --git a/tests/unit/test_toolchain_triple.cpp b/tests/unit/test_toolchain_triple.cpp index b0adb5524..ded0c22f1 100644 --- a/tests/unit/test_toolchain_triple.cpp +++ b/tests/unit/test_toolchain_triple.cpp @@ -1,72 +1,74 @@ -#include import std; +import mcpplibs.mtest; import mcpp.toolchain.triple; +using namespace mcpplibs::mtest; + using namespace mcpp::toolchain::triple; namespace triple = mcpp::toolchain::triple; // ── parse: canonical spellings round-trip ──────────────────────────────────── -TEST(Triple, ParsesCanonicalThreeSegment) { +UTest parses_canonical_three_segment{"Triple/ParsesCanonicalThreeSegment", [] { auto t = parse("x86_64-linux-musl"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->arch, "x86_64"); - EXPECT_EQ(t->os, "linux"); - EXPECT_EQ(t->env, "musl"); - EXPECT_EQ(t->str(), "x86_64-linux-musl"); - EXPECT_TRUE(t->is_musl()); - EXPECT_TRUE(is_known_target(*t)); -} - -TEST(Triple, ParsesCanonicalWindowsGnu) { + expect(fatal(t.has_value())); + expect(eq(t->arch, "x86_64")); + expect(eq(t->os, "linux")); + expect(eq(t->env, "musl")); + expect(eq(t->str(), "x86_64-linux-musl")); + expect(t->is_musl()); + expect(is_known_target(*t)); +}}; + +UTest parses_canonical_windows_gnu{"Triple/ParsesCanonicalWindowsGnu", [] { auto t = parse("x86_64-windows-gnu"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->str(), "x86_64-windows-gnu"); - EXPECT_TRUE(t->is_windows_gnu()); - EXPECT_TRUE(t->is_pe()); - EXPECT_EQ(t->family(), "windows"); - EXPECT_TRUE(is_known_target(*t)); -} - -TEST(Triple, ParsesCanonicalMacos) { + expect(fatal(t.has_value())); + expect(eq(t->str(), "x86_64-windows-gnu")); + expect(t->is_windows_gnu()); + expect(t->is_pe()); + expect(eq(t->family(), "windows")); + expect(is_known_target(*t)); +}}; + +UTest parses_canonical_macos{"Triple/ParsesCanonicalMacos", [] { auto t = parse("aarch64-macos"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->arch, "aarch64"); - EXPECT_EQ(t->os, "macos"); - EXPECT_EQ(t->env, ""); - EXPECT_EQ(t->str(), "aarch64-macos"); - EXPECT_EQ(t->family(), "unix"); -} + expect(fatal(t.has_value())); + expect(eq(t->arch, "aarch64")); + expect(eq(t->os, "macos")); + expect(eq(t->env, "")); + expect(eq(t->str(), "aarch64-macos")); + expect(eq(t->family(), "unix")); +}}; // ── parse: alias spellings normalize to canonical ──────────────────────────── -TEST(Triple, NormalizesGnuMingwSpelling) { +UTest normalizes_gnu_mingw_spelling{"Triple/NormalizesGnuMingwSpelling", [] { // GNU vendor triple: "w64" = vendor, "mingw32" = os segment (historical — // 64-bit targets still say mingw32). Canonicalizes to windows-gnu. auto t = parse("x86_64-w64-mingw32"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->str(), "x86_64-windows-gnu"); - EXPECT_TRUE(t->is_windows_gnu()); -} - -TEST(Triple, NormalizesFourSegmentRustSpellings) { - EXPECT_EQ(parse("x86_64-unknown-linux-musl")->str(), "x86_64-linux-musl"); - EXPECT_EQ(parse("x86_64-unknown-linux-gnu")->str(), "x86_64-linux-gnu"); - EXPECT_EQ(parse("x86_64-pc-windows-msvc")->str(), "x86_64-windows-msvc"); - EXPECT_EQ(parse("x86_64-pc-windows-gnu")->str(), "x86_64-windows-gnu"); -} - -TEST(Triple, NormalizesAppleSpellings) { + expect(fatal(t.has_value())); + expect(eq(t->str(), "x86_64-windows-gnu")); + expect(t->is_windows_gnu()); +}}; + +UTest normalizes_four_segment_rust_spellings{"Triple/NormalizesFourSegmentRustSpellings", [] { + expect(eq(parse("x86_64-unknown-linux-musl")->str(), "x86_64-linux-musl")); + expect(eq(parse("x86_64-unknown-linux-gnu")->str(), "x86_64-linux-gnu")); + expect(eq(parse("x86_64-pc-windows-msvc")->str(), "x86_64-windows-msvc")); + expect(eq(parse("x86_64-pc-windows-gnu")->str(), "x86_64-windows-gnu")); +}}; + +UTest normalizes_apple_spellings{"Triple/NormalizesAppleSpellings", [] { // arm64 → aarch64 (GNU arch spelling); darwin/macosx version suffixes drop. - EXPECT_EQ(parse("arm64-apple-darwin24.1.0")->str(), "aarch64-macos"); - EXPECT_EQ(parse("arm64-apple-macosx15.0")->str(), "aarch64-macos"); - EXPECT_EQ(parse("aarch64-apple-darwin")->str(), "aarch64-macos"); -} + expect(eq(parse("arm64-apple-darwin24.1.0")->str(), "aarch64-macos")); + expect(eq(parse("arm64-apple-macosx15.0")->str(), "aarch64-macos")); + expect(eq(parse("aarch64-apple-darwin")->str(), "aarch64-macos")); +}}; -TEST(Triple, NormalizesBareLinuxToGnuEnv) { - EXPECT_EQ(parse("x86_64-linux")->str(), "x86_64-linux-gnu"); -} +UTest normalizes_bare_linux_to_gnu_env{"Triple/NormalizesBareLinuxToGnuEnv", [] { + expect(eq(parse("x86_64-linux")->str(), "x86_64-linux-gnu")); +}}; // ── resolve_request: the REQUEST is completed from the vocabulary ──────────── // @@ -75,28 +77,28 @@ TEST(Triple, NormalizesBareLinuxToGnuEnv) { // row a request that named no C library should resolve to — and it is the one // that consults `kKnownTargets`. -TEST(TripleRequest, ASupportedLexicalDefaultIsKept) { +UTest a_supported_lexical_default_is_kept{"TripleRequest/ASupportedLexicalDefaultIsKept", [] { // x86_64-linux-gnu is `verified`, so nothing moves. This is the control for // the case below: a fix that sent every bare `-linux` to musl would pass the // aarch64 test and break every project on the planet. auto r = triple::resolve_request(*parse("x86_64-linux")); - EXPECT_EQ(r.triple.str(), "x86_64-linux-gnu"); - EXPECT_FALSE(r.completedFromVocabulary); - EXPECT_FALSE(r.ambiguous); -} + expect(eq(r.triple.str(), "x86_64-linux-gnu")); + expect(!r.completedFromVocabulary); + expect(!r.ambiguous); +}}; -TEST(TripleRequest, NativeArm64UsesTheSupportedGnuRow) { +UTest native_arm64_uses_the_supported_gnu_row{"TripleRequest/NativeArm64UsesTheSupportedGnuRow", [] { // The supported GNU lexical default outranks the musl sibling. auto r = triple::resolve_request(*parse("aarch64-linux")); - EXPECT_EQ(r.triple.str(), "aarch64-linux-gnu"); - EXPECT_FALSE(r.completedFromVocabulary); + expect(eq(r.triple.str(), "aarch64-linux-gnu")); + expect(!r.completedFromVocabulary); // mcpp CHOOSING A ROW IS NOT THE PROJECT NAMING A C LIBRARY. `envExplicit` // feeds the request/fact comparison and the report's display name; setting // it here would make mcpp compare its own answer against itself. - EXPECT_FALSE(r.triple.envExplicit); -} + expect(!r.triple.envExplicit); +}}; -TEST(TripleRequest, ABareLinuxTripleIsNeverCompletedToAndroid) { +UTest a_bare_linux_triple_is_never_completed_to_android{"TripleRequest/ABareLinuxTripleIsNeverCompletedToAndroid", [] { // `aarch64-linux` asks for a C LIBRARY to be filled in. `android` shares // the `arch-os` prefix because its kernel is Linux -- the modelling // decision that makes every Linux-shaped answer in the tree right about it @@ -108,168 +110,164 @@ TEST(TripleRequest, ABareLinuxTripleIsNeverCompletedToAndroid) { // outcomes of that ambiguity are wrong -- refusing a request with an // obvious answer, or answering it with bionic. auto r = triple::resolve_request(*parse("aarch64-linux")); - EXPECT_EQ(r.triple.str(), "aarch64-linux-gnu"); - EXPECT_FALSE(r.completedFromVocabulary); - EXPECT_FALSE(r.ambiguous); + expect(eq(r.triple.str(), "aarch64-linux-gnu")); + expect(!r.completedFromVocabulary); + expect(!r.ambiguous); // Not offered as a suggestion either: `siblings` is what the diagnostic // prints, and naming it there would suggest building for another platform. for (auto& s : r.siblings) - EXPECT_EQ(s.find("android"), std::string::npos) << s; + expect(eq(s.find("android"), std::string::npos)) << s; // x86_64 has the same shape and a different supported set, so it exercises // the exclusion independently rather than re-testing one row. auto x = triple::resolve_request(*parse("x86_64-linux")); for (auto& s : x.siblings) - EXPECT_EQ(s.find("android"), std::string::npos) << s; + expect(eq(s.find("android"), std::string::npos)) << s; // AND THE WRITTEN SPELLING IS STILL HONOURED. The exclusion is about // filling a gap, not about refusing a request -- an explicit env returns // before the candidate loop runs. auto explicitAndroid = triple::resolve_request(*parse("aarch64-linux-android")); - EXPECT_EQ(explicitAndroid.triple.str(), "aarch64-linux-android"); - EXPECT_FALSE(explicitAndroid.completedFromVocabulary); -} + expect(eq(explicitAndroid.triple.str(), "aarch64-linux-android")); + expect(!explicitAndroid.completedFromVocabulary); +}}; -TEST(TripleRequest, AWrittenSegmentIsARequestAndIsNotRevised) { +UTest a_written_segment_is_a_request_and_is_not_revised{"TripleRequest/AWrittenSegmentIsARequestAndIsNotRevised", [] { // An explicit environment segment is preserved independently of tier. auto r = triple::resolve_request(*parse("aarch64-linux-gnu")); - EXPECT_EQ(r.triple.str(), "aarch64-linux-gnu"); - EXPECT_FALSE(r.completedFromVocabulary); -} + expect(eq(r.triple.str(), "aarch64-linux-gnu")); + expect(!r.completedFromVocabulary); +}}; -TEST(TripleRequest, AFamilyWithNoSupportedRowKeepsTheLexicalFillAndReportsItsRows) { +UTest a_family_with_no_supported_row_keeps_the_lexical_fill_and_reports_its_rows{"TripleRequest/AFamilyWithNoSupportedRowKeepsTheLexicalFillAndReportsItsRows", [] { // riscv64-linux-musl is `planned` and riscv64-linux-gnu does not exist at // all, so the fill named a row outside the vocabulary and the refusal came // out as `unknown target 'riscv64-linux'` — false, the family is registered. auto r = triple::resolve_request(*parse("riscv64-linux")); - EXPECT_EQ(r.triple.str(), "riscv64-linux-gnu"); - EXPECT_FALSE(r.completedFromVocabulary); - ASSERT_EQ(r.siblings.size(), 1u); - EXPECT_EQ(r.siblings[0], "riscv64-linux-musl"); - EXPECT_TRUE(r.supported.empty()); -} - -TEST(TripleRequest, MacosCarriesNoEnvAndIsLeftAlone) { - EXPECT_EQ(triple::resolve_request(*parse("aarch64-macos")).triple.str(), - "aarch64-macos"); - // x86_64-macos is `planned` with no sibling: the tier gate still speaks. + expect(eq(r.triple.str(), "riscv64-linux-gnu")); + expect(!r.completedFromVocabulary); + expect(fatal(eq(r.siblings.size(), 1u))); + expect(eq(r.siblings[0], "riscv64-linux-musl")); + expect(r.supported.empty()); +}}; + +UTest macos_carries_no_env_and_is_left_alone{"TripleRequest/MacosCarriesNoEnvAndIsLeftAlone", [] { + expect(eq(triple::resolve_request(*parse("aarch64-macos")).triple.str(), "aarch64-macos")); + // x86_64-macos is `planned`; the musl identity beside it is the one supported row. auto r = triple::resolve_request(*parse("x86_64-macos")); - EXPECT_EQ(r.triple.str(), "x86_64-macos"); - EXPECT_TRUE(r.supported.empty()); -} + expect(eq(r.triple.str(), "x86_64-macos-musl")); + expect(eq(r.supported.size(), 1u)); +}}; -TEST(TripleRequest, WindowsAndBareMetalDefaultsAreSupportedRows) { +UTest windows_and_bare_metal_defaults_are_supported_rows{"TripleRequest/WindowsAndBareMetalDefaultsAreSupportedRows", [] { // Both lexical fills (`gnu` on Windows, `elf` freestanding) name supported // rows, so completion is a no-op — recorded so that a future row change // which breaks that shows up here rather than in a user's build. - EXPECT_EQ(triple::resolve_request(*parse("x86_64-windows")).triple.str(), - "x86_64-windows-gnu"); - EXPECT_EQ(triple::resolve_request(*parse("riscv64-none")).triple.str(), - "riscv64-none-elf"); -} + expect(eq(triple::resolve_request(*parse("x86_64-windows")).triple.str(), "x86_64-windows-gnu")); + expect(eq(triple::resolve_request(*parse("riscv64-none")).triple.str(), "riscv64-none-elf")); +}}; -TEST(TripleRequest, EverySupportedRowIsReachableFromItsOwnSpelling) { +UTest every_supported_row_is_reachable_from_its_own_spelling{"TripleRequest/EverySupportedRowIsReachableFromItsOwnSpelling", [] { // The completion must never turn a written, supported triple into a // different one. Checked across the whole vocabulary rather than by example, // because the failure mode is one row nobody thought to test. for (auto const& row : triple::known_targets()) { if (row.tier == "planned") continue; auto t = parse(row.canonical); - ASSERT_TRUE(t.has_value()) << row.canonical; - EXPECT_EQ(triple::resolve_request(*t).triple.str(), - std::string(row.canonical)); + expect(fatal(t.has_value())) << row.canonical; + expect(eq(triple::resolve_request(*t).triple.str(), std::string(row.canonical))); } -} +}}; -TEST(Triple, NormalizesDumpmachineSpellings) { +UTest normalizes_dumpmachine_spellings{"Triple/NormalizesDumpmachineSpellings", [] { // What real toolchains report via -dumpmachine. - EXPECT_EQ(parse("x86_64-pc-linux-gnu")->str(), "x86_64-linux-gnu"); - EXPECT_EQ(parse("x86_64-linux-gnu")->str(), "x86_64-linux-gnu"); - EXPECT_EQ(parse("aarch64-linux-musl")->str(), "aarch64-linux-musl"); -} + expect(eq(parse("x86_64-pc-linux-gnu")->str(), "x86_64-linux-gnu")); + expect(eq(parse("x86_64-linux-gnu")->str(), "x86_64-linux-gnu")); + expect(eq(parse("aarch64-linux-musl")->str(), "aarch64-linux-musl")); +}}; // ── parse: rejects non-triples ─────────────────────────────────────────────── -TEST(Triple, RejectsNonTriples) { - EXPECT_FALSE(parse("").has_value()); - EXPECT_FALSE(parse("gcc").has_value()); - EXPECT_FALSE(parse("x86_64").has_value()); - EXPECT_FALSE(parse("x86_64-linux-mus").has_value()); // typo'd env segment - EXPECT_FALSE(parse("wasm32-wasi").has_value()); // outside the language -} +UTest rejects_non_triples{"Triple/RejectsNonTriples", [] { + expect(!(parse("").has_value())); + expect(!(parse("gcc").has_value())); + expect(!(parse("x86_64").has_value())); + expect(!(parse("x86_64-linux-mus").has_value())); // typo'd env segment + expect(!(parse("wasm32-wasi").has_value())); // outside the language +}}; // ── known-target vocabulary ────────────────────────────────────────────────── -TEST(Triple, KnownTargetTableExposesTierAndPins) { +UTest known_target_table_exposes_tier_and_pins{"Triple/KnownTargetTableExposesTierAndPins", [] { auto [name, tier] = std::pair{"x86_64-linux-musl", "verified"}; auto t = parse(name); - ASSERT_TRUE(t.has_value()); + expect(fatal(t.has_value())); auto* info = find_known_target(*t); - ASSERT_NE(info, nullptr); - EXPECT_EQ(info->tier, tier); - EXPECT_EQ(info->pin, "gcc@16.1.0"); - EXPECT_TRUE(info->defaultStatic); + expect(fatal(ne(info, nullptr))); + expect(eq(info->tier, tier)); + expect(eq(info->pin, "gcc@16.1.0")); + expect(bool(info->defaultStatic)); auto w = parse("x86_64-w64-mingw32"); // alias resolves to the same row - ASSERT_TRUE(w.has_value()); + expect(fatal(w.has_value())); auto* winfo = find_known_target(*w); - ASSERT_NE(winfo, nullptr); - EXPECT_EQ(winfo->canonical, "x86_64-windows-gnu"); - EXPECT_EQ(winfo->pin, "gcc@16.1.0"); - EXPECT_TRUE(winfo->defaultStatic); -} + expect(fatal(ne(winfo, nullptr))); + expect(eq(winfo->canonical, "x86_64-windows-gnu")); + expect(eq(winfo->pin, "gcc@16.1.0")); + expect(bool(winfo->defaultStatic)); +}}; -TEST(Triple, UnknownButParseableTripleIsNotKnown) { +UTest unknown_but_parseable_triple_is_not_known{"Triple/UnknownButParseableTripleIsNotKnown", [] { auto t = parse("riscv64-linux-gnu"); - ASSERT_TRUE(t.has_value()); - EXPECT_FALSE(is_known_target(*t)); -} + expect(fatal(t.has_value())); + expect(!is_known_target(*t)); +}}; // ── did-you-mean ───────────────────────────────────────────────────────────── -TEST(Triple, DidYouMeanCatchesTypos) { +UTest did_you_mean_catches_typos{"Triple/DidYouMeanCatchesTypos", [] { auto s = did_you_mean("x86_64-linux-mus"); - ASSERT_TRUE(s.has_value()); - EXPECT_EQ(*s, "x86_64-linux-musl"); + expect(fatal(s.has_value())); + expect(eq(*s, "x86_64-linux-musl")); auto w = did_you_mean("x86_64-w64-mingw"); - ASSERT_TRUE(w.has_value()); - EXPECT_EQ(*w, "x86_64-windows-gnu"); -} + expect(fatal(w.has_value())); + expect(eq(*w, "x86_64-windows-gnu")); +}}; -TEST(Triple, DidYouMeanStaysQuietOnGarbage) { - EXPECT_FALSE(did_you_mean("totally-unrelated-string-xyz").has_value()); -} +UTest did_you_mean_stays_quiet_on_garbage{"Triple/DidYouMeanStaysQuietOnGarbage", [] { + expect(!(did_you_mean("totally-unrelated-string-xyz").has_value())); +}}; // ── nasm output format ─────────────────────────────────────────────────────── -TEST(Triple, NasmFormatCoversX86Targets) { - EXPECT_EQ(parse("x86_64-linux-gnu")->nasm_format(), "elf64"); - EXPECT_EQ(parse("x86_64-linux-musl")->nasm_format(), "elf64"); - EXPECT_EQ(parse("x86_64-windows-gnu")->nasm_format(), "win64"); - EXPECT_EQ(parse("x86_64-windows-msvc")->nasm_format(), "win64"); - EXPECT_EQ(parse("x86_64-macos")->nasm_format(), "macho64"); - EXPECT_EQ(parse("i686-linux-gnu")->nasm_format(), "elf32"); - EXPECT_EQ(parse("i686-windows-gnu")->nasm_format(), "win32"); -} - -TEST(Triple, NasmFormatIsNulloptOffX86) { +UTest nasm_format_covers_x86_targets{"Triple/NasmFormatCoversX86Targets", [] { + expect(eq(parse("x86_64-linux-gnu")->nasm_format(), "elf64")); + expect(eq(parse("x86_64-linux-musl")->nasm_format(), "elf64")); + expect(eq(parse("x86_64-windows-gnu")->nasm_format(), "win64")); + expect(eq(parse("x86_64-windows-msvc")->nasm_format(), "win64")); + expect(eq(parse("x86_64-macos")->nasm_format(), "macho64")); + expect(eq(parse("i686-linux-gnu")->nasm_format(), "elf32")); + expect(eq(parse("i686-windows-gnu")->nasm_format(), "win32")); +}}; + +UTest nasm_format_is_nullopt_off_x86{"Triple/NasmFormatIsNulloptOffX86", [] { // NASM is an x86-family assembler; non-x86 targets have no format and the // caller must hard-error (with a cfg-gating hint) instead of guessing. - EXPECT_FALSE(parse("aarch64-linux-musl")->nasm_format().has_value()); - EXPECT_FALSE(parse("aarch64-macos")->nasm_format().has_value()); - EXPECT_FALSE(parse("riscv64-linux-musl")->nasm_format().has_value()); -} + expect(!(parse("aarch64-linux-musl")->nasm_format().has_value())); + expect(!(parse("aarch64-macos")->nasm_format().has_value())); + expect(!(parse("riscv64-linux-musl")->nasm_format().has_value())); +}}; // ── host ───────────────────────────────────────────────────────────────────── -TEST(Triple, HostTripleIsCanonicalAndNonEmpty) { +UTest host_triple_is_canonical_and_non_empty{"Triple/HostTripleIsCanonicalAndNonEmpty", [] { auto h = host_triple(); - EXPECT_FALSE(h.empty()); + expect(!h.empty()); auto reparsed = parse(h.str()); - ASSERT_TRUE(reparsed.has_value()); - EXPECT_EQ(reparsed->str(), h.str()); -} + expect(fatal(reparsed.has_value())); + expect(eq(reparsed->str(), h.str())); +}}; // ── bare metal: `none` is a vendor segment AND an OS segment ───────────────── // @@ -279,56 +277,56 @@ TEST(Triple, HostTripleIsCanonicalAndNonEmpty) { // would fall through to the host and the build would report success while // producing an x86-64 binary. -TEST(Triple, NoneIsTheOsWhenNoOtherOsToken) { +UTest none_is_the_os_when_no_other_os_token{"Triple/NoneIsTheOsWhenNoOtherOsToken", [] { auto t = parse("riscv64-none-elf"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->arch, "riscv64"); - EXPECT_EQ(t->os, "none"); - EXPECT_EQ(t->env, "elf"); - EXPECT_EQ(t->str(), "riscv64-none-elf"); - EXPECT_TRUE(t->is_freestanding()); - EXPECT_TRUE(is_known_target(*t)); + expect(fatal(t.has_value())); + expect(eq(t->arch, "riscv64")); + expect(eq(t->os, "none")); + expect(eq(t->env, "elf")); + expect(eq(t->str(), "riscv64-none-elf")); + expect(t->is_freestanding()); + expect(is_known_target(*t)); // No OS means no cfg family — a freestanding target is neither unix nor // windows, and claiming either would enable the wrong cfg branches. - EXPECT_EQ(t->family(), ""); -} + expect(eq(t->family(), "")); +}}; -TEST(Triple, NoneStaysAVendorWhenARealOsFollows) { +UTest none_stays_a_vendor_when_a_real_os_follows{"Triple/NoneStaysAVendorWhenARealOsFollows", [] { auto t = parse("x86_64-none-linux-gnu"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->os, "linux"); - EXPECT_EQ(t->env, "gnu"); - EXPECT_FALSE(t->is_freestanding()); - EXPECT_EQ(t->family(), "unix"); -} - -TEST(Triple, Riscv32BareMetalParses) { + expect(fatal(t.has_value())); + expect(eq(t->os, "linux")); + expect(eq(t->env, "gnu")); + expect(!t->is_freestanding()); + expect(eq(t->family(), "unix")); +}}; + +UTest riscv32_bare_metal_parses{"Triple/Riscv32BareMetalParses", [] { auto t = parse("riscv32-none-elf"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->str(), "riscv32-none-elf"); - EXPECT_TRUE(t->is_freestanding()); - EXPECT_TRUE(is_known_target(*t)); -} + expect(fatal(t.has_value())); + expect(eq(t->str(), "riscv32-none-elf")); + expect(t->is_freestanding()); + expect(is_known_target(*t)); +}}; -TEST(Triple, HostedTargetsAreNotFreestanding) { +UTest hosted_targets_are_not_freestanding{"Triple/HostedTargetsAreNotFreestanding", [] { for (const char* s : { "x86_64-linux-gnu", "x86_64-linux-musl", "x86_64-windows-gnu", "aarch64-macos" }) { auto t = parse(s); - ASSERT_TRUE(t.has_value()) << s; - EXPECT_FALSE(t->is_freestanding()) << s; + expect(fatal(t.has_value())) << s; + expect(!t->is_freestanding()) << s; } -} +}}; -TEST(Triple, BareMetalEabiSpellings) { +UTest bare_metal_eabi_spellings{"Triple/BareMetalEabiSpellings", [] { // Accepted only under os=none, so a hosted triple cannot pick them up. auto hf = parse("arm-none-eabihf"); - ASSERT_TRUE(hf.has_value()); - EXPECT_EQ(hf->os, "none"); - EXPECT_EQ(hf->env, "eabihf"); + expect(fatal(hf.has_value())); + expect(eq(hf->os, "none")); + expect(eq(hf->env, "eabihf")); // Parsing is not the same as being supported: arm is not in the // vocabulary table yet, and the target gate is what says so. - EXPECT_FALSE(is_known_target(*hf)); -} + expect(!is_known_target(*hf)); +}}; // ── effective_sysroot: the project's override, the target row otherwise ────── // @@ -344,33 +342,32 @@ const std::string kEmpty = ""; // asked for no C library" the same value, and a kernel project would silently // get picolibc back. -TEST(Triple, EffectiveSysrootFallsBackToTheTargetRow) { +UTest effective_sysroot_falls_back_to_the_target_row{"Triple/EffectiveSysrootFallsBackToTheTargetRow", [] { auto t = parse("riscv64-none-elf"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(effective_sysroot(*t, nullptr), "xim:picolibc-riscv@1.8.12"); -} + expect(fatal(t.has_value())); + expect(eq(effective_sysroot(*t, nullptr), "xim:picolibc-riscv@1.8.12")); +}}; -TEST(Triple, EffectiveSysrootHonoursAnOverride) { +UTest effective_sysroot_honours_an_override{"Triple/EffectiveSysrootHonoursAnOverride", [] { auto t = parse("riscv64-none-elf"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(effective_sysroot(*t, &kNewlib), - "xim:newlib-riscv@4.4"); -} + expect(fatal(t.has_value())); + expect(eq(effective_sysroot(*t, &kNewlib), "xim:newlib-riscv@4.4")); +}}; -TEST(Triple, EffectiveSysrootEmptyStringIsTheZeroLibcTier) { +UTest effective_sysroot_empty_string_is_the_zero_libc_tier{"Triple/EffectiveSysrootEmptyStringIsTheZeroLibcTier", [] { // Present-and-empty must NOT fall through to the target row. Measured // end-to-end alongside this: with `sysroot = ""`, `#include ` // stops resolving and a self-contained image links at 108 bytes. auto t = parse("riscv64-none-elf"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(effective_sysroot(*t, &kEmpty), ""); -} + expect(fatal(t.has_value())); + expect(eq(effective_sysroot(*t, &kEmpty), "")); +}}; -TEST(Triple, EffectiveSysrootIsEmptyForHostedTargets) { +UTest effective_sysroot_is_empty_for_hosted_targets{"Triple/EffectiveSysrootIsEmptyForHostedTargets", [] { auto t = parse("x86_64-linux-musl"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(effective_sysroot(*t, nullptr), ""); -} + expect(fatal(t.has_value())); + expect(eq(effective_sysroot(*t, nullptr), "")); +}}; // ── llvm_triple: the spelling a compiler takes, which is not the one mcpp uses @@ -379,49 +376,49 @@ TEST(Triple, EffectiveSysrootIsEmptyForHostedTargets) { // operating system carries a version. Every one of these was a defect the // three-host matrix found rather than a test. -TEST(Triple, LlvmTripleLinuxGnu) { +UTest llvm_triple_linux_gnu{"Triple/LlvmTripleLinuxGnu", [] { auto t = parse("x86_64-linux-gnu"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->llvm_triple(""), "x86_64-unknown-linux-gnu"); -} + expect(fatal(t.has_value())); + expect(eq(t->llvm_triple(""), "x86_64-unknown-linux-gnu")); +}}; -TEST(Triple, LlvmTripleLinuxMusl) { +UTest llvm_triple_linux_musl{"Triple/LlvmTripleLinuxMusl", [] { auto t = parse("aarch64-linux-musl"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->llvm_triple(""), "aarch64-unknown-linux-musl"); -} + expect(fatal(t.has_value())); + expect(eq(t->llvm_triple(""), "aarch64-unknown-linux-musl")); +}}; -TEST(Triple, LlvmTripleWindowsGnu) { +UTest llvm_triple_windows_gnu{"Triple/LlvmTripleWindowsGnu", [] { auto t = parse("x86_64-windows-gnu"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->llvm_triple(""), "x86_64-w64-windows-gnu"); -} + expect(fatal(t.has_value())); + expect(eq(t->llvm_triple(""), "x86_64-w64-windows-gnu")); +}}; // `aarch64` becomes `arm64` and the version is appended. A build that // emitted mcpp's own spelling produced `--target=aarch64-macos`, which clang // accepts as a triple it has never heard of and then treats as bare-metal // aarch64 — the module and its importers then agree with each other and with // nothing else. -TEST(Triple, LlvmTripleMacosRenamesArchAndCarriesVersion) { +UTest llvm_triple_macos_renames_arch_and_carries_version{"Triple/LlvmTripleMacosRenamesArchAndCarriesVersion", [] { auto t = parse("aarch64-macos"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->llvm_triple("14.0"), "arm64-apple-macos14.0"); - EXPECT_EQ(t->llvm_triple("15.2"), "arm64-apple-macos15.2"); -} + expect(fatal(t.has_value())); + expect(eq(t->llvm_triple("14.0"), "arm64-apple-macos14.0")); + expect(eq(t->llvm_triple("15.2"), "arm64-apple-macos15.2")); +}}; -TEST(Triple, LlvmTripleMacosX86KeepsArchName) { +UTest llvm_triple_macos_x86_keeps_arch_name{"Triple/LlvmTripleMacosX86KeepsArchName", [] { auto t = parse("x86_64-macos"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->llvm_triple("14.0"), "x86_64-apple-macos14.0"); -} + expect(fatal(t.has_value())); + expect(eq(t->llvm_triple("14.0"), "x86_64-apple-macos14.0")); +}}; // A freestanding triple is already LLVM's own form, so it is returned as // written rather than expanded into four fields. -TEST(Triple, LlvmTripleFreestandingIsUnchanged) { +UTest llvm_triple_freestanding_is_unchanged{"Triple/LlvmTripleFreestandingIsUnchanged", [] { auto t = parse("riscv64-none-elf"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->llvm_triple(""), "riscv64-none-elf"); -} + expect(fatal(t.has_value())); + expect(eq(t->llvm_triple(""), "riscv64-none-elf")); +}}; // ── The object format each target has, asked of the triple rather than of the // machine running the build. @@ -432,30 +429,30 @@ TEST(Triple, LlvmTripleFreestandingIsUnchanged) { // question about the HOST, and produced opposite errors on opposite hosts: an // ELF contract for a Mach-O when built on Linux, and a Mach-O contract for an // ELF when built on macOS. -TEST(Triple, OsFieldIdentifiesMacosWithoutTheWordApple) { +UTest os_field_identifies_macos_without_the_word_apple{"Triple/OsFieldIdentifiesMacosWithoutTheWordApple", [] { auto t = parse("aarch64-macos"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->os, "macos"); - EXPECT_FALSE(t->is_pe()); - EXPECT_FALSE(t->is_freestanding()); - EXPECT_EQ(t->str().find("apple"), std::string::npos); - EXPECT_EQ(t->str().find("darwin"), std::string::npos); -} - -TEST(Triple, OsFieldIdentifiesPe) { + expect(fatal(t.has_value())); + expect(eq(t->os, "macos")); + expect(!t->is_pe()); + expect(!t->is_freestanding()); + expect(eq(t->str().find("apple"), std::string::npos)); + expect(eq(t->str().find("darwin"), std::string::npos)); +}}; + +UTest os_field_identifies_pe{"Triple/OsFieldIdentifiesPe", [] { auto t = parse("x86_64-windows-gnu"); - ASSERT_TRUE(t.has_value()); - EXPECT_TRUE(t->is_pe()); - EXPECT_EQ(t->os, "windows"); -} + expect(fatal(t.has_value())); + expect(t->is_pe()); + expect(eq(t->os, "windows")); +}}; -TEST(Triple, OsFieldIdentifiesFreestanding) { +UTest os_field_identifies_freestanding{"Triple/OsFieldIdentifiesFreestanding", [] { auto t = parse("riscv64-none-elf"); - ASSERT_TRUE(t.has_value()); - EXPECT_TRUE(t->is_freestanding()); - EXPECT_EQ(t->os, "none"); - EXPECT_FALSE(t->is_pe()); -} + expect(fatal(t.has_value())); + expect(t->is_freestanding()); + expect(eq(t->os, "none")); + expect(!t->is_pe()); +}}; // ── The env segment may be declined on every platform ──────────────────────── // @@ -468,33 +465,33 @@ TEST(Triple, OsFieldIdentifiesFreestanding) { // The identity stays total: `x86_64-windows` IS `x86_64-windows-gnu`, one output // directory and one cache key. What differs is `envExplicit`, which records that // nothing was asked for. -TEST(Triple, TheEnvSegmentMayBeDeclinedOnWindows) { +UTest the_env_segment_may_be_declined_on_windows{"Triple/TheEnvSegmentMayBeDeclinedOnWindows", [] { auto t = parse("x86_64-windows"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->str(), "x86_64-windows-gnu"); - EXPECT_EQ(t->env, "gnu"); - EXPECT_FALSE(t->envExplicit); -} + expect(fatal(t.has_value())); + expect(eq(t->str(), "x86_64-windows-gnu")); + expect(eq(t->env, "gnu")); + expect(!t->envExplicit); +}}; -TEST(Triple, TheEnvSegmentMayBeDeclinedOnBareMetal) { +UTest the_env_segment_may_be_declined_on_bare_metal{"Triple/TheEnvSegmentMayBeDeclinedOnBareMetal", [] { auto t = parse("riscv64-none"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->str(), "riscv64-none-elf"); - EXPECT_TRUE(t->is_freestanding()); - EXPECT_FALSE(t->envExplicit); -} + expect(fatal(t.has_value())); + expect(eq(t->str(), "riscv64-none-elf")); + expect(t->is_freestanding()); + expect(!t->envExplicit); +}}; // Writing it out is still a request, and still recorded as one. -TEST(Triple, WritingTheSegmentOutIsStillARequest) { +UTest writing_the_segment_out_is_still_a_request{"Triple/WritingTheSegmentOutIsStillARequest", [] { auto g = parse("x86_64-windows-gnu"); - ASSERT_TRUE(g.has_value()); - EXPECT_TRUE(g->envExplicit); + expect(fatal(g.has_value())); + expect(bool(g->envExplicit)); auto m = parse("x86_64-windows-msvc"); - ASSERT_TRUE(m.has_value()); - EXPECT_EQ(m->env, "msvc"); - EXPECT_TRUE(m->envExplicit); -} + expect(fatal(m.has_value())); + expect(eq(m->env, "msvc")); + expect(bool(m->envExplicit)); +}}; // THE FILL IS `gnu` AND NOT THE HOST'S OWN ENV. // @@ -502,19 +499,19 @@ TEST(Triple, WritingTheSegmentOutIsStillARequest) { // give one command a different identity — a different output directory and // cache key — on each host, and a target's identity may not depend on where it // was built. The two spellings must therefore agree on every machine. -TEST(Triple, TheFillDoesNotDependOnTheHost) { - EXPECT_EQ(parse("x86_64-windows")->str(), parse("x86_64-windows-gnu")->str()); - EXPECT_EQ(parse("riscv64-none")->str(), parse("riscv64-none-elf")->str()); -} +UTest the_fill_does_not_depend_on_the_host{"Triple/TheFillDoesNotDependOnTheHost", [] { + expect(eq(parse("x86_64-windows")->str(), parse("x86_64-windows-gnu")->str())); + expect(eq(parse("riscv64-none")->str(), parse("riscv64-none-elf")->str())); +}}; // macOS declines the segment by having none at all, which was already true and // is asserted here so the four platforms are covered in one place. -TEST(Triple, MacosCarriesNoSegmentToDecline) { +UTest macos_carries_no_segment_to_decline{"Triple/MacosCarriesNoSegmentToDecline", [] { auto t = parse("aarch64-macos"); - ASSERT_TRUE(t.has_value()); - EXPECT_TRUE(t->env.empty()); - EXPECT_FALSE(t->envExplicit); -} + expect(fatal(t.has_value())); + expect(t->env.empty()); + expect(!t->envExplicit); +}}; // NO TWO ROWS MAY SHARE A CANONICAL NAME, AND THIS WAS NOT A HYPOTHETICAL. // @@ -527,26 +524,26 @@ TEST(Triple, MacosCarriesNoSegmentToDecline) { // Caught by reading the diff, which is the wrong mechanism: a duplicate is a // property of the table and a machine can see it. The cost of the check is four // lines. -TEST(Triple, TheTargetTableHasNoDuplicateNames) { +UTest the_target_table_has_no_duplicate_names{"Triple/TheTargetTableHasNoDuplicateNames", [] { std::vector seen; for (const auto& info : known_targets()) { for (auto s : seen) - EXPECT_NE(s, info.canonical) + expect(ne(s, info.canonical)) << "duplicate row in kKnownTargets: " << info.canonical; seen.push_back(info.canonical); } -} +}}; // And every row must parse to itself: a canonical name that does not survive a // round trip through `parse`/`str` is a row no `--target` can reach. -TEST(Triple, EveryTableRowIsItsOwnCanonicalForm) { +UTest every_table_row_is_its_own_canonical_form{"Triple/EveryTableRowIsItsOwnCanonicalForm", [] { for (const auto& info : known_targets()) { auto t = parse(info.canonical); - ASSERT_TRUE(t.has_value()) << info.canonical; - EXPECT_EQ(t->str(), info.canonical) + expect(fatal(t.has_value())) << info.canonical; + expect(eq(t->str(), info.canonical)) << info.canonical << " does not round-trip"; } -} +}}; // ── The object-format axis, and the three platforms it exists for ─────────── // @@ -556,111 +553,111 @@ TEST(Triple, EveryTableRowIsItsOwnCanonicalForm) { // it silently answers ELF, which is what every `else` branch in the tree // assumes. -TEST(Triple, TheObjectFormatIsOneAnswerAndNotADerivation) { - EXPECT_EQ(parse("x86_64-linux-gnu")->object_format(), ObjectFormat::Elf); - EXPECT_EQ(parse("x86_64-linux-musl")->object_format(), ObjectFormat::Elf); - EXPECT_EQ(parse("aarch64-linux-android")->object_format(), ObjectFormat::Elf); - EXPECT_EQ(parse("riscv64-none-elf")->object_format(), ObjectFormat::Elf); - EXPECT_EQ(parse("x86_64-windows-gnu")->object_format(), ObjectFormat::Pe); - EXPECT_EQ(parse("x86_64-windows-msvc")->object_format(), ObjectFormat::Pe); - EXPECT_EQ(parse("aarch64-macos")->object_format(), ObjectFormat::MachO); - EXPECT_EQ(parse("aarch64-ios")->object_format(), ObjectFormat::MachO); - EXPECT_EQ(parse("wasm32-emscripten")->object_format(), ObjectFormat::Wasm); -} - -TEST(Triple, TheFormatQuestionIsNotTheOperatingSystemQuestion) { +UTest the_object_format_is_one_answer_and_not_a_derivation{"Triple/TheObjectFormatIsOneAnswerAndNotADerivation", [] { + expect(eq(parse("x86_64-linux-gnu")->object_format(), ObjectFormat::Elf)); + expect(eq(parse("x86_64-linux-musl")->object_format(), ObjectFormat::Elf)); + expect(eq(parse("aarch64-linux-android")->object_format(), ObjectFormat::Elf)); + expect(eq(parse("riscv64-none-elf")->object_format(), ObjectFormat::Elf)); + expect(eq(parse("x86_64-windows-gnu")->object_format(), ObjectFormat::Pe)); + expect(eq(parse("x86_64-windows-msvc")->object_format(), ObjectFormat::Pe)); + expect(eq(parse("aarch64-macos")->object_format(), ObjectFormat::MachO)); + expect(eq(parse("aarch64-ios")->object_format(), ObjectFormat::MachO)); + expect(eq(parse("wasm32-emscripten")->object_format(), ObjectFormat::Wasm)); +}}; + +UTest the_format_question_is_not_the_operating_system_question{"Triple/TheFormatQuestionIsNotTheOperatingSystemQuestion", [] { // The two axes were conflated before `ObjectFormat` existed, and merging // them is the mistake it replaces: a bare-metal image is ELF with no OS, // and a wasm module has an OS-like layer and is not ELF. auto bare = parse("riscv64-none-elf"); - EXPECT_TRUE(bare->is_freestanding()); - EXPECT_EQ(bare->object_format(), ObjectFormat::Elf); + expect(bare->is_freestanding()); + expect(eq(bare->object_format(), ObjectFormat::Elf)); auto web = parse("wasm32-emscripten"); - EXPECT_FALSE(web->is_freestanding()); - EXPECT_TRUE(web->is_wasm()); -} - -TEST(Triple, IsPeAndIsMachOReadTheSingleAnswer) { - EXPECT_TRUE (parse("x86_64-windows-gnu")->is_pe()); - EXPECT_FALSE(parse("x86_64-windows-gnu")->is_mach_o()); - EXPECT_TRUE (parse("aarch64-ios")->is_mach_o()); - EXPECT_FALSE(parse("aarch64-ios")->is_pe()); + expect(!web->is_freestanding()); + expect(web->is_wasm()); +}}; + +UTest is_pe_and_is_mach_o_read_the_single_answer{"Triple/IsPeAndIsMachOReadTheSingleAnswer", [] { + expect(parse("x86_64-windows-gnu")->is_pe()); + expect(!(parse("x86_64-windows-gnu")->is_mach_o())); + expect(parse("aarch64-ios")->is_mach_o()); + expect(!(parse("aarch64-ios")->is_pe())); // The row that used to answer this by `os == "windows"` and would have // answered ELF for wasm. - EXPECT_FALSE(parse("wasm32-emscripten")->is_pe()); - EXPECT_FALSE(parse("wasm32-emscripten")->is_mach_o()); -} + expect(!(parse("wasm32-emscripten")->is_pe())); + expect(!(parse("wasm32-emscripten")->is_mach_o())); +}}; -TEST(Triple, AndroidIsAnEnvOnALinuxOs) { +UTest android_is_an_env_on_a_linux_os{"Triple/AndroidIsAnEnvOnALinuxOs", [] { auto t = parse("aarch64-linux-android"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->arch, "aarch64"); + expect(fatal(t.has_value())); + expect(eq(t->arch, "aarch64")); // THE PLACEMENT IS THE MODELLING DECISION. The kernel is Linux, so ELF, // the `unix` family and `nasm -f elf64` are all already right; an // `os = "android"` would have made every one of them wrong by default. - EXPECT_EQ(t->os, "linux"); - EXPECT_EQ(t->env, "android"); - EXPECT_TRUE(t->is_android()); - EXPECT_EQ(t->family(), "unix"); - EXPECT_EQ(t->str(), "aarch64-linux-android"); - EXPECT_EQ(t->llvm_triple(), "aarch64-unknown-linux-android"); + expect(eq(t->os, "linux")); + expect(eq(t->env, "android")); + expect(t->is_android()); + expect(eq(t->family(), "unix")); + expect(eq(t->str(), "aarch64-linux-android")); + expect(eq(t->llvm_triple(), "aarch64-unknown-linux-android")); // `androideabi` is the 32-bit ARM spelling of the same env: the EABI half // is the calling convention, which the arch segment already carries. auto eabi = parse("armv7a-linux-androideabi"); - ASSERT_TRUE(eabi.has_value()); - EXPECT_EQ(eabi->env, "android"); + expect(fatal(eabi.has_value())); + expect(eq(eabi->env, "android")); // The env fill must not reach an Android request. `x86_64-linux` is still // `gnu`; `x86_64-linux-android` is not. - EXPECT_EQ(parse("x86_64-linux")->env, "gnu"); - EXPECT_EQ(parse("x86_64-linux-android")->env, "android"); -} + expect(eq(parse("x86_64-linux")->env, "gnu")); + expect(eq(parse("x86_64-linux-android")->env, "android")); +}}; -TEST(Triple, IosIsAppleWithoutBeingMacos) { +UTest ios_is_apple_without_being_macos{"Triple/IosIsAppleWithoutBeingMacos", [] { auto t = parse("aarch64-ios"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->os, "ios"); - EXPECT_TRUE(t->env.empty()); - EXPECT_EQ(t->family(), "unix"); - EXPECT_EQ(t->str(), "aarch64-ios"); + expect(fatal(t.has_value())); + expect(eq(t->os, "ios")); + expect(t->env.empty()); + expect(eq(t->family(), "unix")); + expect(eq(t->str(), "aarch64-ios")); // Apple's own spelling of the architecture, as the macOS branch already // produces. No deployment target is baked in: that flag belongs to the // layer that owns the SDK, and a default here would be a second answer. - EXPECT_EQ(t->llvm_triple(), "arm64-apple-ios"); + expect(eq(t->llvm_triple(), "arm64-apple-ios")); // A SITE THAT MEANS "APPLE" AND ASKS "macOS" GETS iOS WRONG IN THE // DIRECTION THAT STILL LINKS, which is why the predicate exists. - EXPECT_TRUE(parse("aarch64-macos")->is_apple()); - EXPECT_TRUE(parse("aarch64-ios")->is_apple()); - EXPECT_FALSE(parse("aarch64-linux-musl")->is_apple()); + expect(parse("aarch64-macos")->is_apple()); + expect(parse("aarch64-ios")->is_apple()); + expect(!(parse("aarch64-linux-musl")->is_apple())); // An effective triple carries the deployment target on this segment. auto eff = parse("arm64-apple-ios17.0"); - ASSERT_TRUE(eff.has_value()); - EXPECT_EQ(eff->os, "ios"); -} + expect(fatal(eff.has_value())); + expect(eq(eff->os, "ios")); +}}; -TEST(Triple, EmscriptenIsAnOsSegmentAndNotAnEnv) { +UTest emscripten_is_an_os_segment_and_not_an_env{"Triple/EmscriptenIsAnOsSegmentAndNotAnEnv", [] { auto t = parse("wasm32-emscripten"); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->arch, "wasm32"); + expect(fatal(t.has_value())); + expect(eq(t->arch, "wasm32")); // It names the platform layer a module is compiled against -- the POSIX // emulation, the filesystem shim, the main loop -- which is the kind of // thing `linux` names and not the kind of thing `musl` names. - EXPECT_EQ(t->os, "emscripten"); - EXPECT_TRUE(t->env.empty()); - EXPECT_EQ(t->str(), "wasm32-emscripten"); - EXPECT_EQ(t->llvm_triple(), "wasm32-unknown-emscripten"); + expect(eq(t->os, "emscripten")); + expect(t->env.empty()); + expect(eq(t->str(), "wasm32-emscripten")); + expect(eq(t->llvm_triple(), "wasm32-unknown-emscripten")); // `unix` on the test the predicate actually applies -- what API surface a // source may assume -- rather than on a claim about wasm. - EXPECT_EQ(t->family(), "unix"); + expect(eq(t->family(), "unix")); // NASM is x86-family by construction and must decline rather than choose. - EXPECT_FALSE(t->nasm_format().has_value()); -} + expect(!(t->nasm_format().has_value())); +}}; -TEST(Triple, EachRowsTierMatchesTheEvidenceThatExistsForIt) { +UTest each_rows_tier_matches_the_evidence_that_exists_for_it{"Triple/EachRowsTierMatchesTheEvidenceThatExistsForIt", [] { // WHAT A TIER ASSERTS, AND THE THREE ANSWERS ARE DIFFERENT CLAIMS. // // verified an artefact was built AND RUN @@ -676,18 +673,18 @@ TEST(Triple, EachRowsTierMatchesTheEvidenceThatExistsForIt) { // printed `1-2-3`. Built and run, so `verified`. auto [name, tier] = std::pair{"wasm32-emscripten", "verified"}; auto t = parse(name); - ASSERT_TRUE(t.has_value()); + expect(fatal(t.has_value())); auto* info = find_known_target(*t); - ASSERT_NE(info, nullptr); - EXPECT_EQ(info->tier, tier); + expect(fatal(ne(info, nullptr))); + expect(eq(info->tier, tier)); // A ROW THAT IS WIRED NAMES ITS PAYLOAD. Without the pin the row's // tier was reachable only through an explicit // `[target.wasm32-emscripten] toolchain = "..."` override, which is // the escape hatch and not the support claim. - EXPECT_EQ(info->pin, "emsdk@6.0.9"); + expect(eq(info->pin, "emsdk@6.0.9")); // No `sysroot` column, and that is a statement: the SDK ships one, so // there is no separate C library for the row to name. - EXPECT_TRUE(info->sysroot.empty()); + expect(info->sysroot.empty()); } // BOTH ANDROID ROWS ARE NOW WIRED, AND `preview` IS THE HONEST TIER. @@ -723,14 +720,14 @@ TEST(Triple, EachRowsTierMatchesTheEvidenceThatExistsForIt) { for (auto [name, tier] : {std::pair{"aarch64-linux-android", "verified"}, std::pair{"x86_64-linux-android", "verified"}}) { auto t = parse(name); - ASSERT_TRUE(t.has_value()) << name; - EXPECT_EQ(t->str(), name); + expect(fatal(t.has_value())) << name; + expect(eq(t->str(), name)); auto* info = find_known_target(*t); - ASSERT_NE(info, nullptr) << name; - EXPECT_EQ(info->tier, tier) << name; - EXPECT_EQ(info->pin, "android-ndk@30.0.16248370") << name; + expect(fatal(ne(info, nullptr))) << name; + expect(eq(info->tier, tier)) << name; + expect(eq(info->pin, "android-ndk@30.0.16248370")) << name; // Same statement the wasm row makes: the SDK ships the sysroot. - EXPECT_TRUE(info->sysroot.empty()) << name; + expect(info->sysroot.empty()) << name; } { @@ -741,14 +738,14 @@ TEST(Triple, EachRowsTierMatchesTheEvidenceThatExistsForIt) { // ARM64 host (SPEC-009 §10.7). auto [name, tier] = std::pair{"aarch64-linux-gnu", "preview"}; auto t = parse(name); - ASSERT_TRUE(t.has_value()); + expect(fatal(t.has_value())); auto* info = find_known_target(*t); - ASSERT_NE(info, nullptr); - EXPECT_EQ(info->tier, tier); - EXPECT_EQ(info->pin, "llvm@23.1.3"); - EXPECT_TRUE(info->sysroot.empty()); - EXPECT_FALSE(info->defaultStatic); - EXPECT_FALSE(t->pin_is_capability()); + expect(fatal(ne(info, nullptr))); + expect(eq(info->tier, tier)); + expect(eq(info->pin, "llvm@23.1.3")); + expect(info->sysroot.empty()); + expect(!info->defaultStatic); + expect(!t->pin_is_capability()); } // THE THREE APPLE ROWS, AND WHAT THE SDK'S LICENCE DOES AND DOES NOT @@ -770,16 +767,16 @@ TEST(Triple, EachRowsTierMatchesTheEvidenceThatExistsForIt) { std::pair{"aarch64-ios-sim", "verified"}, std::pair{"x86_64-ios-sim", "verified"}}) { auto t = parse(name); - ASSERT_TRUE(t.has_value()) << name; - EXPECT_EQ(t->str(), name); + expect(fatal(t.has_value())) << name; + expect(eq(t->str(), name)); auto* info = find_known_target(*t); - ASSERT_NE(info, nullptr) << name; - EXPECT_EQ(info->tier, tier) << name; - EXPECT_EQ(info->pin, "llvm@23.1.3") << name; - EXPECT_TRUE(info->sysroot.empty()) << name; - EXPECT_FALSE(t->pin_is_capability()) << name; + expect(fatal(ne(info, nullptr))) << name; + expect(eq(info->tier, tier)) << name; + expect(eq(info->pin, "llvm@23.1.3")) << name; + expect(info->sysroot.empty()) << name; + expect(!t->pin_is_capability()) << name; } -} +}}; // ─── The iOS deployment target is said in exactly one place ──────────────── // @@ -796,7 +793,7 @@ TEST(Triple, EachRowsTierMatchesTheEvidenceThatExistsForIt) { // this tree would notice the version silently leaving the triple. It cannot be // an end-to-end criterion either -- the SDK gate refuses before the toolchain // is resolved, so a host without Xcode never prints the effective triple. -TEST(Triple, TheIosDeploymentTargetIsCarriedByTheEffectiveTripleAlone) { +UTest the_ios_deployment_target_is_carried_by_the_effective_triple_alone{"Triple/TheIosDeploymentTargetIsCarriedByTheEffectiveTripleAlone", [] { struct Case { std::string_view canonical, unversioned, versioned; }; const Case cases[] = { { "aarch64-ios", "arm64-apple-ios", @@ -808,22 +805,21 @@ TEST(Triple, TheIosDeploymentTargetIsCarriedByTheEffectiveTripleAlone) { }; for (auto const& c : cases) { auto t = parse(c.canonical); - ASSERT_TRUE(t.has_value()) << c.canonical; - EXPECT_EQ(t->llvm_triple("18.0"), c.versioned) << c.canonical; + expect(fatal(t.has_value())) << c.canonical; + expect(eq(t->llvm_triple("18.0"), c.versioned)) << c.canonical; // AND AN UNSTATED VERSION LEAVES THE SEGMENT ALONE rather than // inventing one. macOS bakes in a floor because its static libc++ // archives have one; iOS takes libc++ from the located SDK, so an // unstated version means the SDK's own default -- which clang supplies // for an Apple target and bionic famously does not. - EXPECT_EQ(t->llvm_triple(), c.unversioned) << c.canonical; + expect(eq(t->llvm_triple(), c.unversioned)) << c.canonical; // THE `-simulator` SUFFIX COMES AFTER THE VERSION, which is Apple's // order and not the other one. `arm64-apple-ios-simulator18.0` is not // a triple clang accepts, and the two spellings differ only in where // four characters sit. - EXPECT_TRUE(t->is_ios()) << c.canonical; - EXPECT_EQ(t->is_ios_simulator(), - c.canonical.find("-sim") != std::string_view::npos) + expect(t->is_ios()) << c.canonical; + expect(eq(t->is_ios_simulator(), c.canonical.find("-sim") != std::string_view::npos)) << c.canonical; } @@ -833,9 +829,9 @@ TEST(Triple, TheIosDeploymentTargetIsCarriedByTheEffectiveTripleAlone) { // later step refuses. auto dev = parse("aarch64-ios"); auto sim = parse("aarch64-ios-sim"); - ASSERT_TRUE(dev.has_value() && sim.has_value()); - EXPECT_NE(dev->llvm_triple("18.0"), sim->llvm_triple("18.0")); -} + expect(fatal(dev.has_value() && sim.has_value())); + expect(ne(dev->llvm_triple("18.0"), sim->llvm_triple("18.0"))); +}}; // A CAPABILITY PIN CANNOT BE OVERRIDDEN; A CONVENTION PIN CAN. // @@ -845,11 +841,11 @@ TEST(Triple, TheIosDeploymentTargetIsCarriedByTheEffectiveTripleAlone) { // and each time the refusal explained a different row: "No gcc payload emits a // PE with a musl C library", printed for a wasm target and then for an Android // one, because a fourth case fell into an `else` written as the third's answer. -TEST(Triple, ExactlyTheseRowsHaveACapabilityPin) { +UTest exactly_these_rows_have_a_capability_pin{"Triple/ExactlyTheseRowsHaveACapabilityPin", [] { std::set capability; for (auto& row : known_targets()) { auto t = parse(row.canonical); - ASSERT_TRUE(t.has_value()) << row.canonical; + expect(fatal(t.has_value())) << row.canonical; if (t->pin_is_capability()) capability.insert(std::string(row.canonical)); } // Every freestanding row, the PE+musl row, wasm, and both Android rows. @@ -858,14 +854,16 @@ TEST(Triple, ExactlyTheseRowsHaveACapabilityPin) { "riscv32-none-elf", "riscv64-none-elf", "thumbv6m-none-eabi", "thumbv7em-none-eabi", "thumbv7em-none-eabihf", "thumbv7m-none-eabi", "thumbv8m.base-none-eabi", "thumbv8m.main-none-eabi", - "thumbv8m.main-none-eabihf", "x86_64-none-elf", + "thumbv8m.main-none-eabihf", "x86_64-none-elf", "i686-none-elf", "x86_64-windows-musl", + // musl on macOS: openkal-platform's hosted part, which pairs with clang only. + "aarch64-macos-musl", "x86_64-macos-musl", "wasm32-emscripten", "aarch64-linux-android", "x86_64-linux-android", // Ok images: clang and lld emit them on every host, as for bare metal. "x86_64-openkal", "aarch64-openkal", }; - EXPECT_EQ(capability, expected); + expect(eq(capability, expected)); // ANDROID IS THE ONE THAT DOES NOT FIT THE OTHERS' REASON, and that is why // it was left out. The other entries are refused because the toolchain @@ -873,85 +871,85 @@ TEST(Triple, ExactlyTheseRowsHaveACapabilityPin) { // What it cannot supply is bionic -- headers, per-API-level stubs, loader // path -- and no package adds those to another compiler. auto android = parse("aarch64-linux-android"); - ASSERT_TRUE(android.has_value()); - EXPECT_TRUE(android->pin_is_capability()); - EXPECT_FALSE(android->is_freestanding()); - EXPECT_FALSE(android->is_wasm()); - EXPECT_FALSE(android->is_pe() && android->is_musl()); + expect(fatal(android.has_value())); + expect(android->pin_is_capability()); + expect(!android->is_freestanding()); + expect(!android->is_wasm()); + expect(!(android->is_pe() && android->is_musl())); // And a hosted row's pin stays a convention: an author who supplies the // system may name any compiler. auto musl = parse("x86_64-linux-musl"); - ASSERT_TRUE(musl.has_value()); - EXPECT_FALSE(musl->pin_is_capability()); -} + expect(fatal(musl.has_value())); + expect(!musl->pin_is_capability()); +}}; // THE SIMULATOR IS A TARGET, NOT A RUNNER, AND IT NEEDS A SPELLING. -TEST(Triple, TheSimulatorRowsCarryEnvSimAndTheirOwnEffectiveTriple) { +UTest the_simulator_rows_carry_env_sim_and_their_own_effective_triple{"Triple/TheSimulatorRowsCarryEnvSimAndTheirOwnEffectiveTriple", [] { // mcpp's three-field form. Rust spells this `aarch64-apple-ios-sim`; the // difference is the vendor segment this table elides everywhere. auto sim = parse("aarch64-ios-sim"); - ASSERT_TRUE(sim.has_value()); - EXPECT_EQ(sim->arch, "aarch64"); - EXPECT_EQ(sim->os, "ios"); - EXPECT_EQ(sim->env, "sim"); - EXPECT_EQ(sim->str(), "aarch64-ios-sim"); - EXPECT_TRUE(sim->is_apple()); - EXPECT_EQ(sim->object_format(), ObjectFormat::MachO); - EXPECT_EQ(sim->family(), "unix"); + expect(fatal(sim.has_value())); + expect(eq(sim->arch, "aarch64")); + expect(eq(sim->os, "ios")); + expect(eq(sim->env, "sim")); + expect(eq(sim->str(), "aarch64-ios-sim")); + expect(sim->is_apple()); + expect(eq(sim->object_format(), ObjectFormat::MachO)); + expect(eq(sim->family(), "unix")); // APPLE'S OWN SPELLING PARSES TO THE SAME ROW. An effective triple clang // prints carries `-simulator`, and a reader who pastes one must not be // told mcpp has never heard of it. auto apple = parse("arm64-apple-ios-simulator"); - ASSERT_TRUE(apple.has_value()); - EXPECT_EQ(apple->str(), "aarch64-ios-sim"); + expect(fatal(apple.has_value())); + expect(eq(apple->str(), "aarch64-ios-sim")); // TWO ROWS THAT MUST NOT SHARE AN IDENTITY. The device and the simulator // have different SDKs and different objects; one identity would put two // targets in one build directory. auto device = parse("aarch64-ios"); - ASSERT_TRUE(device.has_value()); - EXPECT_NE(device->str(), sim->str()); - EXPECT_TRUE(device->env.empty()); + expect(fatal(device.has_value())); + expect(ne(device->str(), sim->str())); + expect(device->env.empty()); // The effective triple differs too, and that is what the SDK selection // downstream keys off. - EXPECT_EQ(sim->llvm_triple({}), "arm64-apple-ios-simulator"); - EXPECT_EQ(device->llvm_triple({}), "arm64-apple-ios"); + expect(eq(sim->llvm_triple({}), "arm64-apple-ios-simulator")); + expect(eq(device->llvm_triple({}), "arm64-apple-ios")); // Both host arches, because the simulator runs the HOST's architecture: a // single row would describe a simulator half the machines cannot run. auto x86sim = parse("x86_64-ios-sim"); - ASSERT_TRUE(x86sim.has_value()); - EXPECT_EQ(x86sim->str(), "x86_64-ios-sim"); - EXPECT_EQ(x86sim->llvm_triple({}), "x86_64-apple-ios-simulator"); -} + expect(fatal(x86sim.has_value())); + expect(eq(x86sim->str(), "x86_64-ios-sim")); + expect(eq(x86sim->llvm_triple({}), "x86_64-apple-ios-simulator")); +}}; // THE API LEVEL IS FUSED ONTO THE ENV SEGMENT, AND IT IS NOT OPTIONAL. -TEST(Triple, AndroidFusesTheApiLevelAndBionicRequiresOne) { +UTest android_fuses_the_api_level_and_bionic_requires_one{"Triple/AndroidFusesTheApiLevelAndBionicRequiresOne", [] { auto t = parse("aarch64-linux-android"); - ASSERT_TRUE(t.has_value()); + expect(fatal(t.has_value())); // Canonical identity carries no level: one row serves every level, which // is why the level is a project decision in `[target.]` and not a // multiplication of the table. - EXPECT_EQ(t->str(), "aarch64-linux-android"); + expect(eq(t->str(), "aarch64-linux-android")); // The effective triple is where it lands. Measured: // `clang -target aarch64-linux-android21 -print-effective-triple` // answers `aarch64-unknown-linux-android21`. - EXPECT_EQ(t->llvm_triple("21"), "aarch64-unknown-linux-android21"); - EXPECT_EQ(t->llvm_triple("24"), "aarch64-unknown-linux-android24"); + expect(eq(t->llvm_triple("21"), "aarch64-unknown-linux-android21")); + expect(eq(t->llvm_triple("24"), "aarch64-unknown-linux-android24")); // WITH NO LEVEL THE FORM IS STILL PRODUCED, and that is deliberate: this // function composes, it does not decide. The refusal lives where the level // is chosen, because bionic's own stops the build -- // "Unversioned target triples are not supported!" -- and the default comes // from the NDK's `meta/platforms.json` rather than from here. - EXPECT_EQ(t->llvm_triple({}), "aarch64-unknown-linux-android"); + expect(eq(t->llvm_triple({}), "aarch64-unknown-linux-android")); // An effective triple with a level parses back to the canonical row. auto back = parse("aarch64-unknown-linux-android21"); - ASSERT_TRUE(back.has_value()); - EXPECT_EQ(back->str(), "aarch64-linux-android"); -} + expect(fatal(back.has_value())); + expect(eq(back->str(), "aarch64-linux-android")); +}}; // DOES THIS TARGET'S TOOLCHAIN ARRIVE WITH ITS OWN COMPLETE SYSTEM? // @@ -967,15 +965,15 @@ TEST(Triple, AndroidFusesTheApiLevelAndBionicRequiresOne) { // // Asserted as an exhaustive statement over the table rather than on examples, // so a new row cannot join the set by accident or be left out of it. -TEST(Triple, OnlyTheSdkTargetsShipTheirOwnSysroot) { +UTest only_the_sdk_targets_ship_their_own_sysroot{"Triple/OnlyTheSdkTargetsShipTheirOwnSysroot", [] { std::set shipsOwn; for (auto& row : known_targets()) { auto t = parse(row.canonical); - ASSERT_TRUE(t.has_value()) << row.canonical; + expect(fatal(t.has_value())) << row.canonical; if (t->has_own_sysroot()) shipsOwn.insert(std::string(row.canonical)); } - EXPECT_EQ(shipsOwn, (std::set{ - "aarch64-linux-android", "x86_64-linux-android", "wasm32-emscripten"})); + expect(eq(shipsOwn, (std::set{ + "aarch64-linux-android", "x86_64-linux-android", "wasm32-emscripten"}))); // `aarch64-ios` is NOT in the set, and that is the interesting exclusion. // The iPhoneOS SDK does ship a sysroot -- but mcpp reaches it with @@ -983,9 +981,9 @@ TEST(Triple, OnlyTheSdkTargetsShipTheirOwnSysroot) { // whether the DRIVER resolves the system without being told, and an // ordinary clang pointed at an SDK does not. auto ios = parse("aarch64-ios"); - ASSERT_TRUE(ios.has_value()); - EXPECT_FALSE(ios->has_own_sysroot()); -} + expect(fatal(ios.has_value())); + expect(!ios->has_own_sysroot()); +}}; // A CAPABILITY PIN CANNOT BE OVERRIDDEN, BECAUSE NOTHING ELSE CAN EMIT THE // TARGET. A convention pin is a preference; this is a fact about the world. @@ -1001,34 +999,34 @@ TEST(Triple, OnlyTheSdkTargetsShipTheirOwnSysroot) { // Asserted as the PAIR, because the whole design is that the two differ: if // `str()` ever carried the version, the output directory and `cfg()` would // multiply per level and the table would need a row for each. -TEST(Triple, TheMinimumPlatformVersionReachesTheEffectiveTripleAndNotTheCanonicalOne) { +UTest the_minimum_platform_version_reaches_the_effective_triple_and_not_the_canonical_one{"Triple/TheMinimumPlatformVersionReachesTheEffectiveTripleAndNotTheCanonicalOne", [] { auto droid = parse("aarch64-linux-android"); - ASSERT_TRUE(droid.has_value()); - EXPECT_EQ(droid->str(), "aarch64-linux-android"); - EXPECT_EQ(droid->llvm_triple("24"), "aarch64-unknown-linux-android24"); - EXPECT_EQ(droid->llvm_triple("21"), "aarch64-unknown-linux-android21"); + expect(fatal(droid.has_value())); + expect(eq(droid->str(), "aarch64-linux-android")); + expect(eq(droid->llvm_triple("24"), "aarch64-unknown-linux-android24")); + expect(eq(droid->llvm_triple("21"), "aarch64-unknown-linux-android21")); // Unset is legal and means the NDK's own default -- what clang normalises // when no level is given. - EXPECT_EQ(droid->llvm_triple(""), "aarch64-unknown-linux-android"); + expect(eq(droid->llvm_triple(""), "aarch64-unknown-linux-android")); // And the canonical form is unmoved by any of it. - EXPECT_EQ(droid->str(), "aarch64-linux-android"); + expect(eq(droid->str(), "aarch64-linux-android")); // macOS, the platform this parameter already served, is unchanged. auto mac = parse("aarch64-macos"); - ASSERT_TRUE(mac.has_value()); - EXPECT_EQ(mac->llvm_triple("15.2"), "arm64-apple-macos15.2"); - EXPECT_EQ(mac->str(), "aarch64-macos"); + expect(fatal(mac.has_value())); + expect(eq(mac->llvm_triple("15.2"), "arm64-apple-macos15.2")); + expect(eq(mac->str(), "aarch64-macos")); // AND NO OTHER ROW TAKES IT. One parameter serves both platforms, so the // risk is a caller handing one platform's answer to another's row -- an // ordinary Linux target must ignore it rather than fuse it. auto lin = parse("x86_64-linux-gnu"); - ASSERT_TRUE(lin.has_value()); - EXPECT_EQ(lin->llvm_triple("24"), "x86_64-unknown-linux-gnu"); + expect(fatal(lin.has_value())); + expect(eq(lin->llvm_triple("24"), "x86_64-unknown-linux-gnu")); auto musl = parse("aarch64-linux-musl"); - ASSERT_TRUE(musl.has_value()); - EXPECT_EQ(musl->llvm_triple("24"), "aarch64-unknown-linux-musl"); -} + expect(fatal(musl.has_value())); + expect(eq(musl->llvm_triple("24"), "aarch64-unknown-linux-musl")); +}}; // THE FOUR-FIELD SPELLING IS WHAT EVERY OTHER TOOLCHAIN PRINTS, so refusing it // is a cost with no design benefit. `em++ -v` passes @@ -1039,7 +1037,7 @@ TEST(Triple, TheMinimumPlatformVersionReachesTheEffectiveTripleAndNotTheCanonica // information for any row in the table -- so this is a normalisation and not a // second vocabulary: `str()` returns the three-field form either way, which is // what keeps the output directory, `cfg()` and the ABI tag single-valued. -TEST(Triple, TheFourFieldSpellingParsesToTheSameCanonicalTriple) { +UTest the_four_field_spelling_parses_to_the_same_canonical_triple{"Triple/TheFourFieldSpellingParsesToTheSameCanonicalTriple", [] { struct Case { const char* spelled; const char* canonical; }; for (auto [spelled, canonical] : { Case{"wasm32-unknown-emscripten", "wasm32-emscripten"}, @@ -1050,29 +1048,29 @@ TEST(Triple, TheFourFieldSpellingParsesToTheSameCanonicalTriple) { Case{"aarch64-unknown-linux-musl","aarch64-linux-musl"}, }) { auto t = parse(spelled); - ASSERT_TRUE(t.has_value()) << spelled; - EXPECT_EQ(t->str(), canonical) << spelled; + expect(fatal(t.has_value())) << spelled; + expect(eq(t->str(), canonical)) << spelled; // And the three-field form still parses to itself, so accepting the // longer spelling did not make the canonical one a second dialect. auto c = parse(canonical); - ASSERT_TRUE(c.has_value()) << canonical; - EXPECT_EQ(c->str(), canonical) << canonical; + expect(fatal(c.has_value())) << canonical; + expect(eq(c->str(), canonical)) << canonical; } -} +}}; -TEST(Triple, WasmJoinsTheCapabilityPinsBecauseNothingElseEmitsIt) { +UTest wasm_joins_the_capability_pins_because_nothing_else_emits_it{"Triple/WasmJoinsTheCapabilityPinsBecauseNothingElseEmitsIt", [] { auto wasm = parse("wasm32-emscripten"); - ASSERT_TRUE(wasm.has_value()); - EXPECT_TRUE(wasm->pin_is_capability()) + expect(fatal(wasm.has_value())); + expect(wasm->pin_is_capability()) << "a declared gcc@16.1.0 would otherwise override emsdk@6.0.9 and " "fail inside a compiler that cannot emit WebAssembly"; // The two that were there before, unchanged. - EXPECT_TRUE(parse("riscv64-none-elf")->pin_is_capability()); - EXPECT_TRUE(parse("x86_64-windows-musl")->pin_is_capability()); + expect(parse("riscv64-none-elf")->pin_is_capability()); + expect(parse("x86_64-windows-musl")->pin_is_capability()); // And an ordinary hosted row is still a convention: a project may name // whichever compiler it likes for its own Linux. - EXPECT_FALSE(parse("x86_64-linux-musl")->pin_is_capability()); + expect(!(parse("x86_64-linux-musl")->pin_is_capability())); // ANDROID IS ONE NOW, AND THIS ASSERTION PREDICTED ITS OWN EXPIRY. It // read EXPECT_FALSE, with the note "the row carries no pin yet [...] it // moves when the row does" -- the row moved, so it did. @@ -1081,16 +1079,16 @@ TEST(Triple, WasmJoinsTheCapabilityPinsBecauseNothingElseEmitsIt) { // toolchain cannot emit the FORMAT; a stock clang emits aarch64 ELF // perfectly well. What it cannot supply is bionic, which lives inside the // NDK and is not packaged onto another compiler. - EXPECT_TRUE(parse("aarch64-linux-android")->pin_is_capability()); - EXPECT_TRUE(parse("x86_64-linux-android")->pin_is_capability()); + expect(parse("aarch64-linux-android")->pin_is_capability()); + expect(parse("x86_64-linux-android")->pin_is_capability()); // The iOS rows are NOT capability pins, and the distinction is worth an // assertion: they carry no pin at all, so there is nothing to override and // nothing to refuse. Their tier is what stops a build, not their pin. - EXPECT_FALSE(parse("aarch64-ios")->pin_is_capability()); - EXPECT_FALSE(parse("aarch64-ios-sim")->pin_is_capability()); -} + expect(!(parse("aarch64-ios")->pin_is_capability())); + expect(!(parse("aarch64-ios-sim")->pin_is_capability())); +}}; -TEST(Triple, TheCanonicalSpellingIsNotSEARCHABLEForAVENDORNAME) { +UTest the_canonical_spelling_is_not_searchable_for_avendorname{"Triple/TheCanonicalSpellingIsNotSEARCHABLEForAVENDORNAME", [] { // WHY A SUBSTRING TEST ON THE CANONICAL TRIPLE IS WRONG, stated as a fact // about the vocabulary rather than as a comment somewhere else. // @@ -1109,23 +1107,22 @@ TEST(Triple, TheCanonicalSpellingIsNotSEARCHABLEForAVENDORNAME) { // ^ the identity ^ what clang is given for (auto name : {"aarch64-macos", "x86_64-macos", "aarch64-ios"}) { auto t = parse(name); - ASSERT_TRUE(t.has_value()) << name; + expect(fatal(t.has_value())) << name; const std::string canonical = t->str(); - EXPECT_EQ(canonical.find("apple"), std::string::npos) << canonical; - EXPECT_EQ(canonical.find("darwin"), std::string::npos) << canonical; + expect(eq(canonical.find("apple"), std::string::npos)) << canonical; + expect(eq(canonical.find("darwin"), std::string::npos)) << canonical; // And the format is right anyway, because it is asked of the fields. - EXPECT_EQ(t->object_format(), ObjectFormat::MachO) << canonical; + expect(eq(t->object_format(), ObjectFormat::MachO)) << canonical; // The LLVM spelling is where the vendor name lives. - EXPECT_NE(t->llvm_triple().find("apple"), std::string::npos) + expect(ne(t->llvm_triple().find("apple"), std::string::npos)) << t->llvm_triple(); } // The one family the substring test got right, and only by luck: the // canonical spelling happens to carry the OS name. - EXPECT_NE(std::string(parse("x86_64-windows-gnu")->str()).find("windows"), - std::string::npos); -} + expect(ne(std::string(parse("x86_64-windows-gnu")->str()).find("windows"), std::string::npos)); +}}; -TEST(Triple, EveryKnownRowHasAnObjectFormatAndNoneFallsThrough) { +UTest every_known_row_has_an_object_format_and_none_falls_through{"Triple/EveryKnownRowHasAnObjectFormatAndNoneFallsThrough", [] { // THE DENOMINATOR IS THE TABLE. A row added without an answer here would // otherwise be covered by a test whose name says every row is -- and the // answer it would get is ELF, because ELF is what every `else` branch in @@ -1133,8 +1130,8 @@ TEST(Triple, EveryKnownRowHasAnObjectFormatAndNoneFallsThrough) { std::size_t elf = 0, macho = 0, pe = 0, wasm = 0; for (auto const& row : known_targets()) { auto t = parse(row.canonical); - ASSERT_TRUE(t.has_value()) << row.canonical; - EXPECT_EQ(t->str(), row.canonical) << "a row that is not its own canonical form"; + expect(fatal(t.has_value())) << row.canonical; + expect(eq(t->str(), row.canonical)) << "a row that is not its own canonical form"; switch (t->object_format()) { case ObjectFormat::Elf: ++elf; break; case ObjectFormat::MachO: ++macho; break; @@ -1144,38 +1141,38 @@ TEST(Triple, EveryKnownRowHasAnObjectFormatAndNoneFallsThrough) { } // Each format has at least one row, which is what makes the axis worth // having: a fourth value with no row would be an enum nothing produces. - EXPECT_GT(elf, 0u); - EXPECT_GT(macho, 0u); - EXPECT_GT(pe, 0u); - EXPECT_EQ(wasm, 1u) << "wasm32-emscripten is the only wasm row today"; - EXPECT_EQ(elf + macho + pe + wasm, known_targets().size()); -} + expect(gt(elf, 0u)); + expect(gt(macho, 0u)); + expect(gt(pe, 0u)); + expect(eq(wasm, 1u)) << "wasm32-emscripten is the only wasm row today"; + expect(eq(elf + macho + pe + wasm, known_targets().size())); +}}; // `-openkal` (design 2026-10-26 part 2, §8.5): an ok image, compiled for // no operating system, named after the convention, and neither freestanding // nor any system's. -TEST(Triple, OpenkalIsAnImageForNoOperatingSystem) { +UTest openkal_is_an_image_for_no_operating_system{"Triple/OpenkalIsAnImageForNoOperatingSystem", [] { using namespace mcpp::toolchain::triple; for (auto spelling : { "x86_64-openkal", "x86_64-unknown-openkal" }) { auto t = parse(spelling); - ASSERT_TRUE(t.has_value()) << spelling; - EXPECT_EQ(t->str(), "x86_64-openkal"); - EXPECT_TRUE(t->is_openkal()); - EXPECT_FALSE(t->is_freestanding()); - EXPECT_EQ(t->family(), ""); - EXPECT_EQ(t->llvm_triple(), "x86_64-unknown-none-elf"); - EXPECT_EQ(t->object_format(), ObjectFormat::Elf); + expect(fatal(t.has_value())) << spelling; + expect(eq(t->str(), "x86_64-openkal")); + expect(t->is_openkal()); + expect(!t->is_freestanding()); + expect(eq(t->family(), "")); + expect(eq(t->llvm_triple(), "x86_64-unknown-none-elf")); + expect(eq(t->object_format(), ObjectFormat::Elf)); } auto a = parse("aarch64-openkal"); - ASSERT_TRUE(a.has_value()); - EXPECT_EQ(a->llvm_triple(), "aarch64-unknown-none-elf"); + expect(fatal(a.has_value())); + expect(eq(a->llvm_triple(), "aarch64-unknown-none-elf")); const ArtifactNaming host{ "", "lib", ".a", ".so", false }; const auto n = artifact_naming(*a, host); - EXPECT_EQ(n.exeSuffix, ".oke"); - EXPECT_EQ(n.sharedLibExt, ".okso"); - EXPECT_EQ(n.staticLibExt, ".a"); + expect(eq(n.exeSuffix, ".oke")); + expect(eq(n.sharedLibExt, ".okso")); + expect(eq(n.staticLibExt, ".a")); const auto* row = find_known_target(*a); - ASSERT_NE(row, nullptr); - EXPECT_EQ(row->pin, "llvm@23.1.3"); - EXPECT_EQ(platform_name(*a), "openkal"); -} + expect(fatal(ne(row, nullptr))); + expect(eq(row->pin, "llvm@23.1.3")); + expect(eq(platform_name(*a), "openkal")); +}}; diff --git a/tests/unit/test_ui_closing_notices.cpp b/tests/unit/test_ui_closing_notices.cpp index 2783d2e5d..5b4720ba7 100644 --- a/tests/unit/test_ui_closing_notices.cpp +++ b/tests/unit/test_ui_closing_notices.cpp @@ -1,43 +1,46 @@ -#include import std; +import mcpplibs.mtest; +import mcpplibs.os.stdio; import mcpp.ui; +using namespace mcpplibs::mtest; + // Closing notices are advisories about the run as a whole. They are printed // once, after the command's own output, as `tip:` lines on stderr; a command // that writes an envelope takes them first and reports them as notes. -TEST(ClosingNotices, AreDeduplicatedAndTakenOnce) { +UTest are_deduplicated_and_taken_once{"ClosingNotices/AreDeduplicatedAndTakenOnce", [] { (void)mcpp::ui::take_closing_notices(); mcpp::ui::add_closing_notice("CODE_A", "the index needs a newer mcpp"); mcpp::ui::add_closing_notice("CODE_A", "the index needs a newer mcpp"); mcpp::ui::add_closing_notice("CODE_B", "a second notice"); auto taken = mcpp::ui::take_closing_notices(); - ASSERT_EQ(taken.size(), 2u); - EXPECT_EQ(taken[0].code, "CODE_A"); - EXPECT_EQ(taken[1].message, "a second notice"); - EXPECT_TRUE(mcpp::ui::take_closing_notices().empty()); -} + expect(fatal(eq(taken.size(), 2u))); + expect(eq(taken[0].code, "CODE_A")); + expect(eq(taken[1].message, "a second notice")); + expect(mcpp::ui::take_closing_notices().empty()); +}}; -TEST(ClosingNotices, PrintAsTipLinesOnStderrAndThenAreGone) { +UTest print_as_tip_lines_on_stderr_and_then_are_gone{"ClosingNotices/PrintAsTipLinesOnStderrAndThenAreGone", [] { (void)mcpp::ui::take_closing_notices(); mcpp::ui::disable_color(); mcpp::ui::add_closing_notice("CODE", "upgrade to see newer packages"); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); mcpp::ui::print_closing_notices(); mcpp::ui::print_closing_notices(); - auto err = testing::internal::GetCapturedStderr(); - EXPECT_EQ(err, "tip: upgrade to see newer packages\n"); -} + auto err = stderrCapture.text(); + expect(eq(err, "tip: upgrade to see newer packages\n")); +}}; -TEST(ClosingNotices, QuietPrintsNothingButStillConsumes) { +UTest quiet_prints_nothing_but_still_consumes{"ClosingNotices/QuietPrintsNothingButStillConsumes", [] { (void)mcpp::ui::take_closing_notices(); mcpp::ui::add_closing_notice("CODE", "not shown under --quiet"); mcpp::ui::set_quiet(true); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); mcpp::ui::print_closing_notices(); - auto err = testing::internal::GetCapturedStderr(); + auto err = stderrCapture.text(); mcpp::ui::set_quiet(false); - EXPECT_TRUE(err.empty()) << err; - EXPECT_TRUE(mcpp::ui::take_closing_notices().empty()); -} + expect(err.empty()) << err; + expect(mcpp::ui::take_closing_notices().empty()); +}}; diff --git a/tests/unit/test_ui_streams.cpp b/tests/unit/test_ui_streams.cpp index aa5f9ccd0..db9f05e98 100644 --- a/tests/unit/test_ui_streams.cpp +++ b/tests/unit/test_ui_streams.cpp @@ -1,9 +1,12 @@ -#include import std; +import mcpplibs.mtest; +import mcpplibs.os.stdio; import mcpp.ui; import mcpp.ui.terminal; +using namespace mcpplibs::mtest; + // Two streams, one rule (output streams plan 2026-10-01, R3): a line that // narrates what the command is doing is written to standard error, and a line // that states its result stays on standard output. `mcpp.ui` makes the choice @@ -19,12 +22,12 @@ struct Captured { // What `body` wrote to each of the two streams. template Captured capture(F&& body) { - testing::internal::CaptureStdout(); - testing::internal::CaptureStderr(); + auto stdoutCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::output).value(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); body(); Captured c; - c.err = testing::internal::GetCapturedStderr(); - c.out = testing::internal::GetCapturedStdout(); + c.err = stderrCapture.text(); + c.out = stdoutCapture.text(); return c; } @@ -36,61 +39,58 @@ void plain_mode() { } // namespace -TEST(UiStreams, NarrationIsStandardError) { - EXPECT_EQ(mcpp::ui::narration_stream(), mcpp::ui::terminal::Stream::Err); -} +UTest narration_is_standard_error{"UiStreams/NarrationIsStandardError", [] { + expect(eq(mcpp::ui::narration_stream(), mcpp::ui::terminal::Stream::Err)); +}}; -TEST(UiStreams, AStepIsNarration) { +UTest a_step_is_narration{"UiStreams/AStepIsNarration", [] { plain_mode(); auto c = capture([] { mcpp::ui::status("Compiling", "app v0.1.0 (.)"); mcpp::ui::info("Downloading", "xim:gcc"); mcpp::ui::line(" a line of a step"); }); - EXPECT_EQ(c.out, "") << c.out; - EXPECT_EQ(c.err, - " Compiling app v0.1.0 (.)\n" + expect(eq(c.out, "")) << c.out; + expect(eq(c.err, " Compiling app v0.1.0 (.)\n" " Downloading xim:gcc\n" - " a line of a step\n"); -} + " a line of a step\n")); +}}; -TEST(UiStreams, FinishedIsNarrationAndFollowsTheLinesBeforeItWithABlankLine) { +UTest finished_is_narration_and_follows_the_lines_before_it_with_a_blank_line{"UiStreams/FinishedIsNarrationAndFollowsTheLinesBeforeItWithABlankLine", [] { plain_mode(); auto c = capture([] { mcpp::ui::status("Compiling", "app v0.1.0 (.)"); mcpp::ui::finished("dev", std::chrono::milliseconds(840), "unoptimized + debuginfo"); }); - EXPECT_EQ(c.out, "") << c.out; - EXPECT_NE(c.err.find(" Finished dev [unoptimized + debuginfo] in 0.84s\n"), - std::string::npos) << c.err; + expect(eq(c.out, "")) << c.out; + expect(ne(c.err.find(" Finished dev [unoptimized + debuginfo] in 0.84s\n"), std::string::npos)) << c.err; // The blank line answers to a narrated line, and the line before it is one. - EXPECT_NE(c.err.find("(.)\n\n Finished"), std::string::npos) << c.err; -} + expect(ne(c.err.find("(.)\n\n Finished"), std::string::npos)) << c.err; +}}; -TEST(UiStreams, AResultStaysOnStandardOutput) { +UTest a_result_stays_on_standard_output{"UiStreams/AResultStaysOnStandardOutput", [] { plain_mode(); auto c = capture([] { mcpp::ui::plain("tests/smoke ... ok (0.01s)"); mcpp::ui::result("test result", "ok. 1 passed; 0 failed; finished in 0.02s"); mcpp::ui::result("workspace result", "ok. 2 member(s); 2 passed"); }); - EXPECT_EQ(c.err, "") << c.err; - EXPECT_EQ(c.out, - "tests/smoke ... ok (0.01s)\n" + expect(eq(c.err, "")) << c.err; + expect(eq(c.out, "tests/smoke ... ok (0.01s)\n" " test result ok. 1 passed; 0 failed; finished in 0.02s\n" - "workspace result ok. 2 member(s); 2 passed\n"); -} + "workspace result ok. 2 member(s); 2 passed\n")); +}}; // The stream is the caller's statement, never the verb's spelling: `status` // narrates whatever its verb reads. -TEST(UiStreams, TheStreamIsNotInferredFromTheVerb) { +UTest the_stream_is_not_inferred_from_the_verb{"UiStreams/TheStreamIsNotInferredFromTheVerb", [] { plain_mode(); auto c = capture([] { mcpp::ui::status("test result", "ok. 1 passed"); }); - EXPECT_EQ(c.out, "") << c.out; - EXPECT_NE(c.err.find("test result ok. 1 passed"), std::string::npos) << c.err; -} + expect(eq(c.out, "")) << c.out; + expect(ne(c.err.find("test result ok. 1 passed"), std::string::npos)) << c.err; +}}; -TEST(UiStreams, WarningsAndErrorsAreStandardError) { +UTest warnings_and_errors_are_standard_error{"UiStreams/WarningsAndErrorsAreStandardError", [] { plain_mode(); auto c = capture([] { mcpp::ui::warning("a warning"); @@ -98,11 +98,11 @@ TEST(UiStreams, WarningsAndErrorsAreStandardError) { mcpp::ui::note("a note"); mcpp::ui::block("diagnostic text"); }); - EXPECT_EQ(c.out, "") << c.out; - EXPECT_EQ(c.err, "warning: a warning\nerror: an error\nnote: a note\ndiagnostic text\n"); -} + expect(eq(c.out, "")) << c.out; + expect(eq(c.err, "warning: a warning\nerror: an error\nnote: a note\ndiagnostic text\n")); +}}; -TEST(UiStreams, QuietSuppressesTheNarrationAndTheBlankLineThatSeparatesIt) { +UTest quiet_suppresses_the_narration_and_the_blank_line_that_separates_it{"UiStreams/QuietSuppressesTheNarrationAndTheBlankLineThatSeparatesIt", [] { plain_mode(); mcpp::ui::set_quiet(true); auto c = capture([] { @@ -112,27 +112,27 @@ TEST(UiStreams, QuietSuppressesTheNarrationAndTheBlankLineThatSeparatesIt) { mcpp::ui::finished("dev", std::chrono::milliseconds(1)); }); mcpp::ui::set_quiet(false); - EXPECT_EQ(c.out, "") << c.out; - EXPECT_EQ(c.err, "") << c.err; -} + expect(eq(c.out, "")) << c.out; + expect(eq(c.err, "")) << c.err; +}}; -TEST(UiStreams, TheBlankLineAfterRunningIsOnTheStreamOfTheRunningLine) { +UTest the_blank_line_after_running_is_on_the_stream_of_the_running_line{"UiStreams/TheBlankLineAfterRunningIsOnTheStreamOfTheRunningLine", [] { plain_mode(); auto c = capture([] { mcpp::ui::status("Running", "`target/bin/app`"); mcpp::ui::line(""); }); - EXPECT_EQ(c.out, "") << c.out; - EXPECT_EQ(c.err, " Running `target/bin/app`\n\n"); -} + expect(eq(c.out, "")) << c.out; + expect(eq(c.err, " Running `target/bin/app`\n\n")); +}}; -TEST(UiStreams, AProgressBarCompletionLineIsNarration) { +UTest a_progress_bar_completion_line_is_narration{"UiStreams/AProgressBarCompletionLineIsNarration", [] { plain_mode(); auto c = capture([] { mcpp::ui::ProgressBar bar("Fetching", "example"); bar.update_bytes(100, 100, 1.0); bar.finish(); }); - EXPECT_EQ(c.out, "") << c.out; - EXPECT_NE(c.err.find("Fetching example done"), std::string::npos) << c.err; -} + expect(eq(c.out, "")) << c.out; + expect(ne(c.err.find("Fetching example done"), std::string::npos)) << c.err; +}}; diff --git a/tests/unit/test_version_floor.cpp b/tests/unit/test_version_floor.cpp index f409d2abd..ce2a61342 100644 --- a/tests/unit/test_version_floor.cpp +++ b/tests/unit/test_version_floor.cpp @@ -1,73 +1,75 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.version_floor; +using namespace mcpplibs::mtest; + using mcpp::build::parse_version_floor; using mcpp::build::parse_version_fact; using mcpp::build::version_at_least; // ── The two spellings ────────────────────────────────────────────────────── -TEST(VersionFloor, ReadsAFloor) { +UTest reads_a_floor{"VersionFloor/ReadsAFloor", [] { auto f = parse_version_floor("cuda.driver >= 12.0"); - ASSERT_TRUE(f.valid()); - EXPECT_EQ(f.name, "cuda.driver"); - EXPECT_EQ(f.version, "12.0"); -} + expect(fatal(bool(f.valid()))); + expect(eq(f.name, "cuda.driver")); + expect(eq(f.version, "12.0")); +}}; -TEST(VersionFloor, ReadsAFact) { +UTest reads_a_fact{"VersionFloor/ReadsAFact", [] { auto f = parse_version_fact("cuda.driver=12.4"); - ASSERT_TRUE(f.valid()); - EXPECT_EQ(f.name, "cuda.driver"); - EXPECT_EQ(f.version, "12.4"); -} + expect(fatal(bool(f.valid()))); + expect(eq(f.name, "cuda.driver")); + expect(eq(f.version, "12.4")); +}}; -TEST(VersionFloor, AFloorIsNotReadAsAFact) { +UTest a_floor_is_not_read_as_a_fact{"VersionFloor/AFloorIsNotReadAsAFact", [] { // Both spellings live in string lists, and `>=` contains `=`. Reading one // as the other would turn "needs at least 12.0" into "this machine has // 12.0" — a requirement silently becoming its own satisfaction. - EXPECT_FALSE(parse_version_fact("cuda.driver >= 12.0").valid()); -} + expect(!(parse_version_fact("cuda.driver >= 12.0").valid())); +}}; -TEST(VersionFloor, AnythingElseIsNotAFloor) { - EXPECT_FALSE(parse_version_floor("cuda.driver").valid()); - EXPECT_FALSE(parse_version_floor("cuda.driver >= ").valid()); - EXPECT_FALSE(parse_version_floor(" >= 12.0").valid()); +UTest anything_else_is_not_a_floor{"VersionFloor/AnythingElseIsNotAFloor", [] { + expect(!(parse_version_floor("cuda.driver").valid())); + expect(!(parse_version_floor("cuda.driver >= ").valid())); + expect(!(parse_version_floor(" >= 12.0").valid())); // Not a version: refused rather than read as `12`. - EXPECT_FALSE(parse_version_floor("cuda.driver >= 12.").valid()); - EXPECT_FALSE(parse_version_floor("cuda.driver >= twelve").valid()); -} + expect(!(parse_version_floor("cuda.driver >= 12.").valid())); + expect(!(parse_version_floor("cuda.driver >= twelve").valid())); +}}; // ── Comparison ───────────────────────────────────────────────────────────── -TEST(VersionFloor, ComparesComponentwise) { - EXPECT_EQ(version_at_least("12.4", "12.0"), std::optional{true}); - EXPECT_EQ(version_at_least("12.0", "12.4"), std::optional{false}); - EXPECT_EQ(version_at_least("13.0", "12.9"), std::optional{true}); +UTest compares_componentwise{"VersionFloor/ComparesComponentwise", [] { + expect(eq(version_at_least("12.4", "12.0"), std::optional{true})); + expect(eq(version_at_least("12.0", "12.4"), std::optional{false})); + expect(eq(version_at_least("13.0", "12.9"), std::optional{true})); // Not lexicographic: 12.10 is above 12.9. - EXPECT_EQ(version_at_least("12.10", "12.9"), std::optional{true}); -} + expect(eq(version_at_least("12.10", "12.9"), std::optional{true})); +}}; -TEST(VersionFloor, AMissingComponentIsZero) { - EXPECT_EQ(version_at_least("12", "12.0"), std::optional{true}); - EXPECT_EQ(version_at_least("12.0", "12"), std::optional{true}); - EXPECT_EQ(version_at_least("12", "12.1"), std::optional{false}); -} +UTest a_missing_component_is_zero{"VersionFloor/AMissingComponentIsZero", [] { + expect(eq(version_at_least("12", "12.0"), std::optional{true})); + expect(eq(version_at_least("12.0", "12"), std::optional{true})); + expect(eq(version_at_least("12", "12.1"), std::optional{false})); +}}; -TEST(VersionFloor, EitherSideUnreadableIsNoClaim) { +UTest either_side_unreadable_is_no_claim{"VersionFloor/EitherSideUnreadableIsNoClaim", [] { // The whole reason this mechanism exists is that a wrong answer is worse // than no answer. A version that cannot be read yields neither. - EXPECT_FALSE(version_at_least("unknown", "12.0").has_value()); - EXPECT_FALSE(version_at_least("12.0", "").has_value()); -} + expect(!(version_at_least("unknown", "12.0").has_value())); + expect(!(version_at_least("12.0", "").has_value())); +}}; -TEST(VersionFloor, NoVendorVocabularyIsRequired) { +UTest no_vendor_vocabulary_is_required{"VersionFloor/NoVendorVocabularyIsRequired", [] { // The name is data. A backend this engine has never heard of compares the // same way, which is what keeps the comparison out of the vendors' reach. auto f = parse_version_floor("some.future.thing >= 4.2.1"); - ASSERT_TRUE(f.valid()); - EXPECT_EQ(f.name, "some.future.thing"); - EXPECT_EQ(version_at_least("4.3", f.version), std::optional{true}); - EXPECT_EQ(version_at_least("4.2", f.version), std::optional{false}); -} + expect(fatal(bool(f.valid()))); + expect(eq(f.name, "some.future.thing")); + expect(eq(version_at_least("4.3", f.version), std::optional{true})); + expect(eq(version_at_least("4.2", f.version), std::optional{false})); +}}; diff --git a/tests/unit/test_version_req.cpp b/tests/unit/test_version_req.cpp index c6108ff46..aac38ceaf 100644 --- a/tests/unit/test_version_req.cpp +++ b/tests/unit/test_version_req.cpp @@ -1,168 +1,170 @@ -#include import std; +import mcpplibs.mtest; import mcpp.version_req; +using namespace mcpplibs::mtest; + using namespace mcpp::version_req; -TEST(VersionReq, ParseVersion) { +UTest version_req_parse_version{"VersionReq/ParseVersion", [] { auto v = parse_version("1.2.3"); - ASSERT_TRUE(v); - EXPECT_EQ(v->major(), 1); EXPECT_EQ(v->minor(), 2); EXPECT_EQ(v->patch(), 3); + expect(fatal(bool(v))); + expect(eq(v->major(), 1)); expect(eq(v->minor(), 2)); expect(eq(v->patch(), 3)); v = parse_version("0.5"); - ASSERT_TRUE(v); - EXPECT_EQ(v->major(), 0); EXPECT_EQ(v->minor(), 5); EXPECT_EQ(v->patch(), 0); + expect(fatal(bool(v))); + expect(eq(v->major(), 0)); expect(eq(v->minor(), 5)); expect(eq(v->patch(), 0)); v = parse_version("7"); - ASSERT_TRUE(v); - EXPECT_EQ(v->major(), 7); EXPECT_EQ(v->minor(), 0); EXPECT_EQ(v->patch(), 0); + expect(fatal(bool(v))); + expect(eq(v->major(), 7)); expect(eq(v->minor(), 0)); expect(eq(v->patch(), 0)); // #363: pre-release is PARSED, not stripped. Build metadata is dropped from // the order (SemVer §10) but the key's identity lives in the literal, which // pm/resolver.cppm carries separately. v = parse_version("1.2.3-beta.1+build42"); - ASSERT_TRUE(v); - EXPECT_EQ(v->str(), "1.2.3-beta.1"); - EXPECT_TRUE(v->isPrerelease()); -} - -TEST(VersionReq, ParseAny) { - EXPECT_TRUE(parse_req("")->any); - EXPECT_TRUE(parse_req("*")->any); - EXPECT_TRUE(parse_req(" * ")->any); -} - -TEST(VersionReq, MatchExact) { - auto r = parse_req("=1.2.3"); ASSERT_TRUE(r); - EXPECT_TRUE (matches(*r, *parse_version("1.2.3"))); - EXPECT_FALSE(matches(*r, *parse_version("1.2.4"))); - EXPECT_FALSE(matches(*r, *parse_version("1.2.2"))); -} - -TEST(VersionReq, MatchCaretBare) { - auto r = parse_req("1.2.3"); ASSERT_TRUE(r); - EXPECT_TRUE (matches(*r, *parse_version("1.2.3"))); - EXPECT_TRUE (matches(*r, *parse_version("1.5.0"))); - EXPECT_TRUE (matches(*r, *parse_version("1.99.99"))); - EXPECT_FALSE(matches(*r, *parse_version("2.0.0"))); - EXPECT_FALSE(matches(*r, *parse_version("1.2.2"))); -} - -TEST(VersionReq, MatchCaretZeroMajor) { - auto r = parse_req("0.5.3"); ASSERT_TRUE(r); - EXPECT_TRUE (matches(*r, *parse_version("0.5.3"))); - EXPECT_TRUE (matches(*r, *parse_version("0.5.99"))); - EXPECT_FALSE(matches(*r, *parse_version("0.6.0"))) << "minor bump kills caret when major==0"; -} - -TEST(VersionReq, MatchTilde) { - auto r = parse_req("~1.2.3"); ASSERT_TRUE(r); - EXPECT_TRUE (matches(*r, *parse_version("1.2.3"))); - EXPECT_TRUE (matches(*r, *parse_version("1.2.99"))); - EXPECT_FALSE(matches(*r, *parse_version("1.3.0"))); - EXPECT_FALSE(matches(*r, *parse_version("1.2.2"))); -} + expect(fatal(bool(v))); + expect(eq(v->str(), "1.2.3-beta.1")); + expect(bool(v->isPrerelease())); +}}; + +UTest parse_any{"VersionReq/ParseAny", [] { + expect(bool(parse_req("")->any)); + expect(bool(parse_req("*")->any)); + expect(bool(parse_req(" * ")->any)); +}}; + +UTest match_exact{"VersionReq/MatchExact", [] { + auto r = parse_req("=1.2.3"); expect(fatal(bool(r))); + expect(matches(*r, *parse_version("1.2.3"))); + expect(!(matches(*r, *parse_version("1.2.4")))); + expect(!(matches(*r, *parse_version("1.2.2")))); +}}; + +UTest match_caret_bare{"VersionReq/MatchCaretBare", [] { + auto r = parse_req("1.2.3"); expect(fatal(bool(r))); + expect(matches(*r, *parse_version("1.2.3"))); + expect(matches(*r, *parse_version("1.5.0"))); + expect(matches(*r, *parse_version("1.99.99"))); + expect(!(matches(*r, *parse_version("2.0.0")))); + expect(!(matches(*r, *parse_version("1.2.2")))); +}}; + +UTest match_caret_zero_major{"VersionReq/MatchCaretZeroMajor", [] { + auto r = parse_req("0.5.3"); expect(fatal(bool(r))); + expect(matches(*r, *parse_version("0.5.3"))); + expect(matches(*r, *parse_version("0.5.99"))); + expect(!(matches(*r, *parse_version("0.6.0")))) << "minor bump kills caret when major==0"; +}}; + +UTest match_tilde{"VersionReq/MatchTilde", [] { + auto r = parse_req("~1.2.3"); expect(fatal(bool(r))); + expect(matches(*r, *parse_version("1.2.3"))); + expect(matches(*r, *parse_version("1.2.99"))); + expect(!(matches(*r, *parse_version("1.3.0")))); + expect(!(matches(*r, *parse_version("1.2.2")))); +}}; // A segment that is not written is a wildcard, not a zero (Cargo's partial // requirements). Reading `^0` as `^0.0.0` made it admit 0.0.0 alone. -TEST(VersionReq, PartialCaretBoundsFollowTheWrittenSegments) { - auto major = parse_req("^0"); ASSERT_TRUE(major); - EXPECT_TRUE (matches(*major, *parse_version("0.0.0"))); - EXPECT_TRUE (matches(*major, *parse_version("0.0.5"))); - EXPECT_TRUE (matches(*major, *parse_version("0.9.3"))); - EXPECT_FALSE(matches(*major, *parse_version("1.0.0"))); - - auto minor = parse_req("^0.0"); ASSERT_TRUE(minor); - EXPECT_TRUE (matches(*minor, *parse_version("0.0.0"))); - EXPECT_TRUE (matches(*minor, *parse_version("0.0.7"))); - EXPECT_FALSE(matches(*minor, *parse_version("0.1.0"))); +UTest partial_caret_bounds_follow_the_written_segments{"VersionReq/PartialCaretBoundsFollowTheWrittenSegments", [] { + auto major = parse_req("^0"); expect(fatal(bool(major))); + expect(matches(*major, *parse_version("0.0.0"))); + expect(matches(*major, *parse_version("0.0.5"))); + expect(matches(*major, *parse_version("0.9.3"))); + expect(!(matches(*major, *parse_version("1.0.0")))); + + auto minor = parse_req("^0.0"); expect(fatal(bool(minor))); + expect(matches(*minor, *parse_version("0.0.0"))); + expect(matches(*minor, *parse_version("0.0.7"))); + expect(!(matches(*minor, *parse_version("0.1.0")))); // Every segment written: the leftmost-nonzero rule is unchanged. - auto patch = parse_req("^0.0.0"); ASSERT_TRUE(patch); - EXPECT_TRUE (matches(*patch, *parse_version("0.0.0"))); - EXPECT_FALSE(matches(*patch, *parse_version("0.0.1"))); + auto patch = parse_req("^0.0.0"); expect(fatal(bool(patch))); + expect(matches(*patch, *parse_version("0.0.0"))); + expect(!(matches(*patch, *parse_version("0.0.1")))); - auto exact = parse_req("^0.0.3"); ASSERT_TRUE(exact); - EXPECT_TRUE (matches(*exact, *parse_version("0.0.3"))); - EXPECT_FALSE(matches(*exact, *parse_version("0.0.4"))); + auto exact = parse_req("^0.0.3"); expect(fatal(bool(exact))); + expect(matches(*exact, *parse_version("0.0.3"))); + expect(!(matches(*exact, *parse_version("0.0.4")))); - auto one = parse_req("^1"); ASSERT_TRUE(one); - EXPECT_TRUE (matches(*one, *parse_version("1.9.9"))); - EXPECT_FALSE(matches(*one, *parse_version("2.0.0"))); + auto one = parse_req("^1"); expect(fatal(bool(one))); + expect(matches(*one, *parse_version("1.9.9"))); + expect(!(matches(*one, *parse_version("2.0.0")))); - auto zeroFive = parse_req("^0.5"); ASSERT_TRUE(zeroFive); - EXPECT_TRUE (matches(*zeroFive, *parse_version("0.5.9"))); - EXPECT_FALSE(matches(*zeroFive, *parse_version("0.6.0"))); -} + auto zeroFive = parse_req("^0.5"); expect(fatal(bool(zeroFive))); + expect(matches(*zeroFive, *parse_version("0.5.9"))); + expect(!(matches(*zeroFive, *parse_version("0.6.0")))); +}}; // A bare requirement is a caret (#866's follow-up), so a bare partial spelling // takes the same wildcard bounds: "0" is <1.0.0 and "0.0" is <0.1.0, where // before #866 both matched 0.0.0 alone. -TEST(VersionReq, BarePartialRequirementsAreCarets) { +UTest bare_partial_requirements_are_carets{"VersionReq/BarePartialRequirementsAreCarets", [] { for (auto [bare, caret] : {std::pair{"0", "^0"}, std::pair{"0.0", "^0.0"}, std::pair{"1", "^1"}, std::pair{"1.2", "^1.2"}}) { - auto b = parse_req(bare); ASSERT_TRUE(b) << bare; - auto c = parse_req(caret); ASSERT_TRUE(c) << caret; + auto b = parse_req(bare); expect(fatal(bool(b))) << bare; + auto c = parse_req(caret); expect(fatal(bool(c))) << caret; for (auto v : {"0.0.0", "0.0.7", "0.1.0", "0.9.3", "1.0.0", "1.2.0", "1.2.9", "1.3.0", "2.0.0"}) - EXPECT_EQ(matches(*b, *parse_version(v)), matches(*c, *parse_version(v))) + expect(eq(matches(*b, *parse_version(v)), matches(*c, *parse_version(v)))) << bare << " against " << v; } - EXPECT_TRUE (matches(*parse_req("0"), *parse_version("0.9.3"))); - EXPECT_FALSE(matches(*parse_req("0"), *parse_version("1.0.0"))); - EXPECT_TRUE (matches(*parse_req("0.0"), *parse_version("0.0.7"))); - EXPECT_FALSE(matches(*parse_req("0.0"), *parse_version("0.1.0"))); -} - -TEST(VersionReq, PartialTildeWithOnlyAMajorHoldsTheMajor) { - auto r = parse_req("~1"); ASSERT_TRUE(r); - EXPECT_TRUE (matches(*r, *parse_version("1.0.0"))); - EXPECT_TRUE (matches(*r, *parse_version("1.1.0"))); - EXPECT_TRUE (matches(*r, *parse_version("1.9.9"))); - EXPECT_FALSE(matches(*r, *parse_version("2.0.0"))); - EXPECT_FALSE(matches(*r, *parse_version("0.9.9"))); - - auto zero = parse_req("~0"); ASSERT_TRUE(zero); - EXPECT_TRUE (matches(*zero, *parse_version("0.4.0"))); - EXPECT_FALSE(matches(*zero, *parse_version("1.0.0"))); + expect(matches(*parse_req("0"), *parse_version("0.9.3"))); + expect(!(matches(*parse_req("0"), *parse_version("1.0.0")))); + expect(matches(*parse_req("0.0"), *parse_version("0.0.7"))); + expect(!(matches(*parse_req("0.0"), *parse_version("0.1.0")))); +}}; + +UTest partial_tilde_with_only_a_major_holds_the_major{"VersionReq/PartialTildeWithOnlyAMajorHoldsTheMajor", [] { + auto r = parse_req("~1"); expect(fatal(bool(r))); + expect(matches(*r, *parse_version("1.0.0"))); + expect(matches(*r, *parse_version("1.1.0"))); + expect(matches(*r, *parse_version("1.9.9"))); + expect(!(matches(*r, *parse_version("2.0.0")))); + expect(!(matches(*r, *parse_version("0.9.9")))); + + auto zero = parse_req("~0"); expect(fatal(bool(zero))); + expect(matches(*zero, *parse_version("0.4.0"))); + expect(!(matches(*zero, *parse_version("1.0.0")))); // With the minor written, tilde holds it, as before. - auto minor = parse_req("~1.2"); ASSERT_TRUE(minor); - EXPECT_TRUE (matches(*minor, *parse_version("1.2.9"))); - EXPECT_FALSE(matches(*minor, *parse_version("1.3.0"))); -} - -TEST(VersionReq, MatchRangeAnd) { - auto r = parse_req(">=1.2, <2.0"); ASSERT_TRUE(r); - EXPECT_TRUE (matches(*r, *parse_version("1.2.0"))); - EXPECT_TRUE (matches(*r, *parse_version("1.99.99"))); - EXPECT_FALSE(matches(*r, *parse_version("1.1.99"))); - EXPECT_FALSE(matches(*r, *parse_version("2.0.0"))); -} - -TEST(VersionReq, ChooseBest) { + auto minor = parse_req("~1.2"); expect(fatal(bool(minor))); + expect(matches(*minor, *parse_version("1.2.9"))); + expect(!(matches(*minor, *parse_version("1.3.0")))); +}}; + +UTest match_range_and{"VersionReq/MatchRangeAnd", [] { + auto r = parse_req(">=1.2, <2.0"); expect(fatal(bool(r))); + expect(matches(*r, *parse_version("1.2.0"))); + expect(matches(*r, *parse_version("1.99.99"))); + expect(!(matches(*r, *parse_version("1.1.99")))); + expect(!(matches(*r, *parse_version("2.0.0")))); +}}; + +UTest choose_best{"VersionReq/ChooseBest", [] { std::vector avail = { *parse_version("1.0.0"), *parse_version("1.2.0"), *parse_version("1.2.5"), *parse_version("2.0.0"), }; - auto r = parse_req("^1.2"); ASSERT_TRUE(r); + auto r = parse_req("^1.2"); expect(fatal(bool(r))); auto pick = choose(*r, avail); - ASSERT_TRUE(pick); - EXPECT_EQ(avail[*pick].str(), "1.2.5"); -} + expect(fatal(bool(pick))); + expect(eq(avail[*pick].str(), "1.2.5")); +}}; -TEST(VersionReq, ChooseUnsatisfiable) { +UTest choose_unsatisfiable{"VersionReq/ChooseUnsatisfiable", [] { std::vector avail = { *parse_version("1.0.0"), *parse_version("2.0.0") }; - auto r = parse_req("^3"); ASSERT_TRUE(r); - EXPECT_FALSE(choose(*r, avail).has_value()); -} + auto r = parse_req("^3"); expect(fatal(bool(r))); + expect(!(choose(*r, avail).has_value())); +}}; -TEST(VersionReq, RejectsGarbage) { - EXPECT_FALSE(parse_req(">=foo").has_value()); - EXPECT_FALSE(parse_version("not-a-version").has_value()); -} +UTest rejects_garbage{"VersionReq/RejectsGarbage", [] { + expect(!(parse_req(">=foo").has_value())); + expect(!(parse_version("not-a-version").has_value())); +}}; // ─── Date-based versions (YYYY.M.D.N), mcpp's own scheme ──────────────── // @@ -170,82 +172,82 @@ TEST(VersionReq, RejectsGarbage) { // every release of a given day compared EQUAL — which silently disabled the // E0006 index-floor check (index_contract.cppm compares mcpp's own version). -TEST(VersionReq, DateVersionParsesFourSegments) { +UTest date_version_parses_four_segments{"VersionReq/DateVersionParsesFourSegments", [] { auto v = parse_version("2026.7.27.1"); - ASSERT_TRUE(v); - EXPECT_EQ(v->major(), 2026); - EXPECT_EQ(v->minor(), 7); - EXPECT_EQ(v->patch(), 27); - EXPECT_EQ(v->revision(), 1); -} - -TEST(VersionReq, DateVersionOrdersWithinTheSameDay) { - EXPECT_LT(*parse_version("2026.7.27.1"), *parse_version("2026.7.27.2")); + expect(fatal(bool(v))); + expect(eq(v->major(), 2026)); + expect(eq(v->minor(), 7)); + expect(eq(v->patch(), 27)); + expect(eq(v->revision(), 1)); +}}; + +UTest date_version_orders_within_the_same_day{"VersionReq/DateVersionOrdersWithinTheSameDay", [] { + expect(lt(*parse_version("2026.7.27.1"), *parse_version("2026.7.27.2"))); // Numeric, not lexicographic: the 10th release of a day must outrank the 9th. - EXPECT_LT(*parse_version("2026.7.27.9"), *parse_version("2026.7.27.10")); - EXPECT_EQ(*parse_version("2026.7.27.3"), *parse_version("2026.7.27.3")); -} + expect(lt(*parse_version("2026.7.27.9"), *parse_version("2026.7.27.10"))); + expect(eq(*parse_version("2026.7.27.3"), *parse_version("2026.7.27.3"))); +}}; -TEST(VersionReq, DateVersionOutranksLegacySemver) { - EXPECT_LT(*parse_version("0.0.109"), *parse_version("2026.7.27.1")); -} +UTest date_version_outranks_legacy_semver{"VersionReq/DateVersionOutranksLegacySemver", [] { + expect(lt(*parse_version("0.0.109"), *parse_version("2026.7.27.1"))); +}}; -TEST(VersionReq, ThreeSegmentVersionsStillCompareEqualAcrossWrittenLength) { +UTest three_segment_versions_still_compare_equal_across_written_length{"VersionReq/ThreeSegmentVersionsStillCompareEqualAcrossWrittenLength", [] { // `components` must not leak into ordering. - EXPECT_EQ(*parse_version("1.2"), *parse_version("1.2.0")); - EXPECT_EQ(*parse_version("1"), *parse_version("1.0.0")); - EXPECT_EQ(*parse_version("1.2.3"), *parse_version("1.2.3")); -} + expect(eq(*parse_version("1.2"), *parse_version("1.2.0"))); + expect(eq(*parse_version("1"), *parse_version("1.0.0"))); + expect(eq(*parse_version("1.2.3"), *parse_version("1.2.3"))); +}}; // str() feeds pm/resolver.cppm's resolved-version return value, which becomes // the lock entry and the xlings wire address — it has to reproduce the // index's literal version key. -TEST(VersionReq, StrKeepsThreeSegmentNormalizationUnchanged) { - EXPECT_EQ(parse_version("1.15.2")->str(), "1.15.2"); - EXPECT_EQ(parse_version("0.5")->str(), "0.5.0"); - EXPECT_EQ(parse_version("7")->str(), "7.0.0"); -} - -TEST(VersionReq, StrRendersTheFourthSegmentWhenWritten) { - EXPECT_EQ(parse_version("2026.7.27.1")->str(), "2026.7.27.1"); +UTest str_keeps_three_segment_normalization_unchanged{"VersionReq/StrKeepsThreeSegmentNormalizationUnchanged", [] { + expect(eq(parse_version("1.15.2")->str(), "1.15.2")); + expect(eq(parse_version("0.5")->str(), "0.5.0")); + expect(eq(parse_version("7")->str(), "7.0.0")); +}}; + +UTest str_renders_the_fourth_segment_when_written{"VersionReq/StrRendersTheFourthSegmentWhenWritten", [] { + expect(eq(parse_version("2026.7.27.1")->str(), "2026.7.27.1")); // A trailing ".0" is mcpp's formal-release marker and must NOT collapse // to three segments — "2026.8.1" is a key that does not exist. - EXPECT_EQ(parse_version("2026.8.1.0")->str(), "2026.8.1.0"); -} + expect(eq(parse_version("2026.8.1.0")->str(), "2026.8.1.0")); +}}; -TEST(VersionReq, CaretUpperBoundZeroesTheFourthSegment) { - auto r = parse_req("^2026.7.27.3"); ASSERT_TRUE(r); - EXPECT_TRUE (matches(*r, *parse_version("2026.7.27.3"))); - EXPECT_TRUE (matches(*r, *parse_version("2026.9.1.0"))); +UTest caret_upper_bound_zeroes_the_fourth_segment{"VersionReq/CaretUpperBoundZeroesTheFourthSegment", [] { + auto r = parse_req("^2026.7.27.3"); expect(fatal(bool(r))); + expect(matches(*r, *parse_version("2026.7.27.3"))); + expect(matches(*r, *parse_version("2026.9.1.0"))); // Upper bound is 2027.0.0.0 — it must not inherit the constraint's ".3". - EXPECT_FALSE(matches(*r, *parse_version("2027.0.0.0"))); - EXPECT_FALSE(matches(*r, *parse_version("2027.0.0.2"))); -} - -TEST(VersionReq, TildeUpperBoundZeroesTheFourthSegment) { - auto r = parse_req("~2026.7.27.3"); ASSERT_TRUE(r); - EXPECT_TRUE (matches(*r, *parse_version("2026.7.99.0"))); - EXPECT_FALSE(matches(*r, *parse_version("2026.8.0.0"))); - EXPECT_FALSE(matches(*r, *parse_version("2026.8.0.5"))); -} - -TEST(VersionReq, ExactMatchDistinguishesSameDayReleases) { - auto r = parse_req("=2026.7.27.1"); ASSERT_TRUE(r); - EXPECT_TRUE (matches(*r, *parse_version("2026.7.27.1"))); - EXPECT_FALSE(matches(*r, *parse_version("2026.7.27.2"))); -} - -TEST(VersionReq, ChooseBestAmongDateVersions) { + expect(!(matches(*r, *parse_version("2027.0.0.0")))); + expect(!(matches(*r, *parse_version("2027.0.0.2")))); +}}; + +UTest tilde_upper_bound_zeroes_the_fourth_segment{"VersionReq/TildeUpperBoundZeroesTheFourthSegment", [] { + auto r = parse_req("~2026.7.27.3"); expect(fatal(bool(r))); + expect(matches(*r, *parse_version("2026.7.99.0"))); + expect(!(matches(*r, *parse_version("2026.8.0.0")))); + expect(!(matches(*r, *parse_version("2026.8.0.5")))); +}}; + +UTest exact_match_distinguishes_same_day_releases{"VersionReq/ExactMatchDistinguishesSameDayReleases", [] { + auto r = parse_req("=2026.7.27.1"); expect(fatal(bool(r))); + expect(matches(*r, *parse_version("2026.7.27.1"))); + expect(!(matches(*r, *parse_version("2026.7.27.2")))); +}}; + +UTest choose_best_among_date_versions{"VersionReq/ChooseBestAmongDateVersions", [] { std::vector avail = { *parse_version("2026.7.27.1"), *parse_version("2026.7.27.10"), *parse_version("2026.7.27.9"), }; - auto r = parse_req("*"); ASSERT_TRUE(r); + auto r = parse_req("*"); expect(fatal(bool(r))); auto pick = choose(*r, avail); - ASSERT_TRUE(pick); - EXPECT_EQ(avail[*pick].str(), "2026.7.27.10"); -} + expect(fatal(bool(pick))); + expect(eq(avail[*pick].str(), "2026.7.27.10")); +}}; // ─── #363: a version is an ORDER; the literal key is the identity ──────── // @@ -255,88 +257,88 @@ TEST(VersionReq, ChooseBestAmongDateVersions) { // 25.0.4+7, `jdk-corretto` ships the five-segment 25.0.4.7.1, and `khistory` // publishes only `pre-v0.0.5`. -TEST(VersionReq, PrereleaseSortsBelowItsRelease) { - EXPECT_LT(*parse_version("1.92.8-docking"), *parse_version("1.92.8")); - EXPECT_LT(*parse_version("1.3.3-beta.1"), *parse_version("1.3.3")); +UTest prerelease_sorts_below_its_release{"VersionReq/PrereleaseSortsBelowItsRelease", [] { + expect(lt(*parse_version("1.92.8-docking"), *parse_version("1.92.8"))); + expect(lt(*parse_version("1.3.3-beta.1"), *parse_version("1.3.3"))); // ...and above the previous release. - EXPECT_LT(*parse_version("1.3.2"), *parse_version("1.3.3-beta.1")); -} + expect(lt(*parse_version("1.3.2"), *parse_version("1.3.3-beta.1"))); +}}; -TEST(VersionReq, PrereleaseIdentifiersCompareBySemVerRules) { +UTest prerelease_identifiers_compare_by_sem_ver_rules{"VersionReq/PrereleaseIdentifiersCompareBySemVerRules", [] { // Numeric identifiers compare numerically, not lexicographically. - EXPECT_LT(*parse_version("1.0.0-rc.9"), *parse_version("1.0.0-rc.10")); + expect(lt(*parse_version("1.0.0-rc.9"), *parse_version("1.0.0-rc.10"))); // Numeric identifiers rank BELOW alphanumeric ones (SemVer §11.4.3). - EXPECT_LT(*parse_version("1.0.0-1"), *parse_version("1.0.0-alpha")); + expect(lt(*parse_version("1.0.0-1"), *parse_version("1.0.0-alpha"))); // A longer identifier list outranks its own prefix (§11.4.4). - EXPECT_LT(*parse_version("1.0.0-alpha"), *parse_version("1.0.0-alpha.1")); + expect(lt(*parse_version("1.0.0-alpha"), *parse_version("1.0.0-alpha.1"))); // Distinct pre-releases are never equal — the property that keeps // 1.92.8-docking and 1.92.8-nodocking addressable as two things. - EXPECT_NE(*parse_version("1.92.8-docking"), *parse_version("1.92.8-legacy")); -} + expect(ne(*parse_version("1.92.8-docking"), *parse_version("1.92.8-legacy"))); +}}; -TEST(VersionReq, RangesDoNotSeePrereleasesUnlessAskedFor) { +UTest ranges_do_not_see_prereleases_unless_asked_for{"VersionReq/RangesDoNotSeePrereleasesUnlessAskedFor", [] { // The #363 headline: ^1.92.8 must not silently pick up the docking branch. - auto caret = parse_req("^1.92.8"); ASSERT_TRUE(caret); - EXPECT_TRUE (matches(*caret, *parse_version("1.92.8"))); - EXPECT_FALSE(matches(*caret, *parse_version("1.92.8-docking"))); + auto caret = parse_req("^1.92.8"); expect(fatal(bool(caret))); + expect(matches(*caret, *parse_version("1.92.8"))); + expect(!(matches(*caret, *parse_version("1.92.8-docking")))); // `*` never reaches a pre-release either. - auto any = parse_req("*"); ASSERT_TRUE(any); - EXPECT_FALSE(matches(*any, *parse_version("1.92.8-docking"))); + auto any = parse_req("*"); expect(fatal(bool(any))); + expect(!(matches(*any, *parse_version("1.92.8-docking")))); // Naming a pre-release at the same numeric tuple opts in. - auto optIn = parse_req("^1.92.8-a"); ASSERT_TRUE(optIn); - EXPECT_TRUE (matches(*optIn, *parse_version("1.92.8-docking"))); + auto optIn = parse_req("^1.92.8-a"); expect(fatal(bool(optIn))); + expect(matches(*optIn, *parse_version("1.92.8-docking"))); // Exact still addresses it (this is the path `imgui = "1.92.8-docking"` // takes after try_merge_semver canonicalises the literal to "=..."). - auto exact = parse_req("=1.92.8-docking"); ASSERT_TRUE(exact); - EXPECT_TRUE (matches(*exact, *parse_version("1.92.8-docking"))); - EXPECT_FALSE(matches(*exact, *parse_version("1.92.8"))); -} + auto exact = parse_req("=1.92.8-docking"); expect(fatal(bool(exact))); + expect(matches(*exact, *parse_version("1.92.8-docking"))); + expect(!(matches(*exact, *parse_version("1.92.8")))); +}}; -TEST(VersionReq, CaretUpperBoundDoesNotLeakIntoTheNextMajorsPrerelease) { +UTest caret_upper_bound_does_not_leak_into_the_next_majors_prerelease{"VersionReq/CaretUpperBoundDoesNotLeakIntoTheNextMajorsPrerelease", [] { // 2.0.0-alpha sorts BELOW 2.0.0, so a plain `v < upper` bound admitted it. - auto r = parse_req("^1.2.3"); ASSERT_TRUE(r); - EXPECT_FALSE(matches(*r, *parse_version("2.0.0-alpha"))); - EXPECT_FALSE(matches(*r, *parse_version("2.0.0"))); - EXPECT_TRUE (matches(*r, *parse_version("1.9.9"))); -} + auto r = parse_req("^1.2.3"); expect(fatal(bool(r))); + expect(!(matches(*r, *parse_version("2.0.0-alpha")))); + expect(!(matches(*r, *parse_version("2.0.0")))); + expect(matches(*r, *parse_version("1.9.9"))); +}}; -TEST(VersionReq, FiveSegmentKeysAreNotTruncated) { +UTest five_segment_keys_are_not_truncated{"VersionReq/FiveSegmentKeysAreNotTruncated", [] { // jdk-corretto's scheme: ..... // Truncating at four made 25.0.4.7.1 and 25.0.4.7.2 compare EQUAL. - EXPECT_LT(*parse_version("25.0.4.7.1"), *parse_version("25.0.4.7.2")); - EXPECT_LT(*parse_version("21.0.12.8.1"), *parse_version("25.0.4.7.1")); - EXPECT_EQ(parse_version("25.0.4.7.1")->str(), "25.0.4.7.1"); + expect(lt(*parse_version("25.0.4.7.1"), *parse_version("25.0.4.7.2"))); + expect(lt(*parse_version("21.0.12.8.1"), *parse_version("25.0.4.7.1"))); + expect(eq(parse_version("25.0.4.7.1")->str(), "25.0.4.7.1")); // Insignificant trailing zeros still compare equal, at any length. - EXPECT_EQ(*parse_version("1.2"), *parse_version("1.2.0.0.0")); -} + expect(eq(*parse_version("1.2"), *parse_version("1.2.0.0.0"))); +}}; -TEST(VersionReq, BuildMetadataIsExcludedFromPrecedenceButParses) { +UTest build_metadata_is_excluded_from_precedence_but_parses{"VersionReq/BuildMetadataIsExcludedFromPrecedenceButParses", [] { auto v = parse_version("25.0.4+7"); - ASSERT_TRUE(v); - EXPECT_EQ(*v, *parse_version("25.0.4")); // SemVer §10 - EXPECT_EQ(v->str(), "25.0.4"); -} + expect(fatal(bool(v))); + expect(eq(*v, *parse_version("25.0.4"))); // SemVer §10 + expect(eq(v->str(), "25.0.4")); +}}; -TEST(VersionReq, UnorderableKeysAreRejectedRatherThanTruncated) { +UTest unorderable_keys_are_rejected_rather_than_truncated{"VersionReq/UnorderableKeysAreRejectedRatherThanTruncated", [] { // llama.cpp-style build numbers, and the two non-numeric keys the real // index publishes. Each must be an ERROR here (→ exact-match-only in the // resolver), never a silent parse that collapses onto something else. - EXPECT_FALSE(parse_version("b10069").has_value()); - EXPECT_FALSE(parse_version("latest").has_value()); - EXPECT_FALSE(parse_version("nightly").has_value()); - EXPECT_FALSE(parse_version("pre-v0.0.5").has_value()); + expect(!(parse_version("b10069").has_value())); + expect(!(parse_version("latest").has_value())); + expect(!(parse_version("nightly").has_value())); + expect(!(parse_version("pre-v0.0.5").has_value())); // Trailing garbage used to parse as 1.2.3 — and therefore compare EQUAL // to it, the same silent merge the extra segments exist to prevent. - EXPECT_FALSE(parse_version("1.2.3abc").has_value()); - EXPECT_FALSE(parse_version("1.2.").has_value()); - EXPECT_FALSE(parse_version("1.2.3-").has_value()); - EXPECT_FALSE(parse_version("1.2.3+").has_value()); -} + expect(!(parse_version("1.2.3abc").has_value())); + expect(!(parse_version("1.2.").has_value())); + expect(!(parse_version("1.2.3-").has_value())); + expect(!(parse_version("1.2.3+").has_value())); +}}; -TEST(VersionReq, ChooseAllReportsPrecedenceTies) { +UTest choose_all_reports_precedence_ties{"VersionReq/ChooseAllReportsPrecedenceTies", [] { // 25.0.4 and 25.0.4+7 are two addresses at one precedence. choose_all must // hand BOTH back so the resolver can refuse instead of guessing. std::vector avail = { @@ -344,23 +346,23 @@ TEST(VersionReq, ChooseAllReportsPrecedenceTies) { *parse_version("25.0.4+7"), *parse_version("21.0.12"), }; - auto r = parse_req("*"); ASSERT_TRUE(r); + auto r = parse_req("*"); expect(fatal(bool(r))); auto best = choose_all(*r, avail); - ASSERT_EQ(best.size(), 2u); - EXPECT_EQ(best[0], 0u); - EXPECT_EQ(best[1], 1u); + expect(fatal(eq(best.size(), 2u))); + expect(eq(best[0], 0u)); + expect(eq(best[1], 1u)); // The ordinary case stays a single answer. std::vector plain = { *parse_version("1.0.0"), *parse_version("1.2.0") }; - EXPECT_EQ(choose_all(*r, plain).size(), 1u); -} + expect(eq(choose_all(*r, plain).size(), 1u)); +}}; -TEST(VersionReq, McppOwnDateVersionsAreUnaffectedByPrereleaseSupport) { +UTest mcpp_own_date_versions_are_unaffected_by_prerelease_support{"VersionReq/McppOwnDateVersionsAreUnaffectedByPrereleaseSupport", [] { // E0006 (index floor) compares mcpp's own version through this parser. // Adding pre-release/metadata handling must not move any of it. - EXPECT_LT(*parse_version("2026.8.3.3"), *parse_version("2026.8.6.3")); - EXPECT_LT(*parse_version("2026.8.1"), *parse_version("2026.8.1.1")); - EXPECT_EQ(*parse_version("2026.8.1.0"), *parse_version("2026.8.1")); - EXPECT_FALSE(parse_version("2026.8.6.3")->isPrerelease()); - EXPECT_EQ(parse_version("2026.8.6.3")->str(), "2026.8.6.3"); -} + expect(lt(*parse_version("2026.8.3.3"), *parse_version("2026.8.6.3"))); + expect(lt(*parse_version("2026.8.1"), *parse_version("2026.8.1.1"))); + expect(eq(*parse_version("2026.8.1.0"), *parse_version("2026.8.1"))); + expect(!(parse_version("2026.8.6.3")->isPrerelease())); + expect(eq(parse_version("2026.8.6.3")->str(), "2026.8.6.3")); +}}; diff --git a/tests/unit/test_windows_command_line.cpp b/tests/unit/test_windows_command_line.cpp index 7a004df1b..a1ec746bd 100644 --- a/tests/unit/test_windows_command_line.cpp +++ b/tests/unit/test_windows_command_line.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.build.command_line; +using namespace mcpplibs::mtest; + // The cmd.exe quoting rules are the easiest thing in mcpp to get wrong and // the hardest to notice: on Linux and macOS the Windows branch is not even // compiled, so nothing here is exercised by an ordinary local run. These @@ -14,10 +16,10 @@ static std::string wrapped_once(std::string_view command) { return "\"" + std::string(command) + "\""; } -TEST(WindowsCommandLine, EmbeddedQuotesAreEscaped) { +UTest embedded_quotes_are_escaped{"WindowsCommandLine/EmbeddedQuotesAreEscaped", [] { auto q = mcpp::build::command_line::quote_windows("a\"b"); - EXPECT_EQ(q, "\"a\\\"b\""); -} + expect(eq(q, "\"a\\\"b\"")); +}}; // ─── A user-authored shell command (project `[hooks]`, #496) ───────────── // @@ -27,7 +29,7 @@ TEST(WindowsCommandLine, EmbeddedQuotesAreEscaped) { // not "the string looks plausible". static std::string cmd_c_tail_under_slash_s(std::string_view line) { constexpr std::string_view kPrefix = "cmd.exe /d /s /c "; - EXPECT_TRUE(line.starts_with(kPrefix)) << line; + expect(line.starts_with(kPrefix)) << line; std::string tail(line.substr(kPrefix.size())); if (tail.empty() || tail.front() != '"') return tail; // rule does not fire tail.erase(0, 1); @@ -39,38 +41,35 @@ static std::string cmd_c_tail_under_slash_s(std::string_view line) { // which is what quoting each argument by the CreateProcess rules gives) they // are no longer switches, and the command tail keeps a quote pair cmd never // consumes — the CI failure this shape exists to prevent. -TEST(WindowsCommandLine, ShellCommandKeepsCmdSwitchesBare) { +UTest shell_command_keeps_cmd_switches_bare{"WindowsCommandLine/ShellCommandKeepsCmdSwitchesBare", [] { auto line = mcpp::build::command_line::windows_shell_command_line("echo hi"); - EXPECT_TRUE(line.starts_with("cmd.exe /d /s /c ")) << line; - EXPECT_EQ(line.find("\"/c\""), std::string::npos) << line; -} + expect(line.starts_with("cmd.exe /d /s /c ")) << line; + expect(eq(line.find("\"/c\""), std::string::npos)) << line; +}}; // A redirect is the ordinary case for a hook that appends to a log, and it is // also the case argv quoting destroys: `>` inside a quoted argument is a // literal, not a redirect. -TEST(WindowsCommandLine, ShellCommandDeliversARedirectVerbatim) { +UTest shell_command_delivers_a_redirect_verbatim{"WindowsCommandLine/ShellCommandDeliversARedirectVerbatim", [] { constexpr std::string_view command = "echo start>>hooks.log"; - EXPECT_EQ(cmd_c_tail_under_slash_s(mcpp::build::command_line::windows_shell_command_line(command)), - command); -} + expect(eq(cmd_c_tail_under_slash_s(mcpp::build::command_line::windows_shell_command_line(command)), command)); +}}; // More than one interior quote pair is exactly where the /C rule bites, and // where the "wrap once" shape earns its keep: whatever the author wrote comes // back byte for byte. -TEST(WindowsCommandLine, ShellCommandDeliversQuotedPathsVerbatim) { +UTest shell_command_delivers_quoted_paths_verbatim{"WindowsCommandLine/ShellCommandDeliversQuotedPathsVerbatim", [] { constexpr std::string_view command = R"("C:\Program Files\notify\notify.exe" --title "build done")"; - EXPECT_EQ(cmd_c_tail_under_slash_s(mcpp::build::command_line::windows_shell_command_line(command)), - command); -} + expect(eq(cmd_c_tail_under_slash_s(mcpp::build::command_line::windows_shell_command_line(command)), command)); +}}; // The POSIX half must keep its own convention — a shared helper that // silently applied Windows quoting on Linux would break every sh command. -TEST(WindowsCommandLine, PosixQuotingIsUnaffected) { - EXPECT_EQ(mcpp::build::command_line::quote_posix("/home/my dir"), - "'/home/my dir'"); - EXPECT_EQ(mcpp::build::command_line::quote_posix("it's"), "'it'\\''s'"); -} +UTest posix_quoting_is_unaffected{"WindowsCommandLine/PosixQuotingIsUnaffected", [] { + expect(eq(mcpp::build::command_line::quote_posix("/home/my dir"), "'/home/my dir'")); + expect(eq(mcpp::build::command_line::quote_posix("it's"), "'it'\\''s'")); +}}; // ── An argument that must survive cmd.exe AND the child's argv parser ─────── @@ -167,34 +166,34 @@ constexpr std::string_view kJsonWithAFloor = } // namespace -TEST(WindowsCommandLine, PlainQuotingLetsCmdSeeARedirection) { +UTest plain_quoting_lets_cmd_see_a_redirection{"WindowsCommandLine/PlainQuotingLetsCmdSeeARedirection", [] { // The state before the fix, stated so the fix below is not asserting // against nothing. This is `shell::quote_windows`, which is correct for // the child and incomplete for cmd. auto line = "xlings.exe interface install_packages --args " + mcpp::build::command_line::quote_windows(kJsonWithAFloor); auto parsed = simulate_cmd_c(wrapped_once(line)); - EXPECT_TRUE(parsed.sawActiveMetacharacter) + expect(bool(parsed.sawActiveMetacharacter)) << "if this ever becomes false the simulator stopped modelling cmd, " "and the test below proves nothing"; -} +}}; -TEST(WindowsCommandLine, MetacharacterQuotingSurvivesBothParsers) { +UTest metacharacter_quoting_survives_both_parsers{"WindowsCommandLine/MetacharacterQuotingSurvivesBothParsers", [] { auto line = "xlings.exe interface install_packages --args " + mcpp::build::command_line::quote_windows_through_cmd(kJsonWithAFloor); auto parsed = simulate_cmd_c(wrapped_once(line)); - EXPECT_FALSE(parsed.sawActiveMetacharacter) + expect(!parsed.sawActiveMetacharacter) << "cmd would still read the `>` in the version floor as a redirection"; // xlings.exe / interface / install_packages / --args / auto argv = msvcrt_argv(parsed.passedOn); - ASSERT_EQ(argv.size(), 5u) << parsed.passedOn; - EXPECT_EQ(argv[4], kJsonWithAFloor) + expect(fatal(eq(argv.size(), 5u))) << parsed.passedOn; + expect(eq(argv[4], kJsonWithAFloor)) << "the child received something other than the JSON that was meant"; -} +}}; -TEST(WindowsCommandLine, NothingToEscapeMeansByteIdenticalToPlainQuoting) { +UTest nothing_to_escape_means_byte_identical_to_plain_quoting{"WindowsCommandLine/NothingToEscapeMeansByteIdenticalToPlainQuoting", [] { // The conservative half of the rule, and it is the half that was measured // the hard way: an earlier version escaped every metacharacter INCLUDING // the quotes, which is defensible on paper and broke every package fetch on @@ -204,22 +203,21 @@ TEST(WindowsCommandLine, NothingToEscapeMeansByteIdenticalToPlainQuoting) { R"({"targets":["mcpplibs:tpl-demo@1.0.0"],"yes":true})", R"({"targets":["compat:widget@1.38.1"],"yes":true})", R"(a plain path C:\Program Files\x)" }) { - EXPECT_EQ(mcpp::build::command_line::quote_windows_through_cmd(plain), - mcpp::build::command_line::quote_windows(plain)) + expect(eq(mcpp::build::command_line::quote_windows_through_cmd(plain), mcpp::build::command_line::quote_windows(plain))) << "a payload with no metacharacter acquired an escape: " << plain; } -} +}}; -TEST(WindowsCommandLine, MetacharacterQuotingIsUnchangedForPlainText) { +UTest metacharacter_quoting_is_unchanged_for_plain_text{"WindowsCommandLine/MetacharacterQuotingIsUnchangedForPlainText", [] { // A payload with nothing to escape must not acquire carets, so the common // case stays legible in a log. constexpr std::string_view plain = R"({"targets":["xim:shaderc@2026.3"]})"; auto quoted = mcpp::build::command_line::quote_windows_through_cmd(plain); auto parsed = simulate_cmd_c(wrapped_once("prog.exe --args " + quoted)); auto argv = msvcrt_argv(parsed.passedOn); - ASSERT_EQ(argv.size(), 3u) << parsed.passedOn; - EXPECT_EQ(argv[2], plain); -} + expect(fatal(eq(argv.size(), 3u))) << parsed.passedOn; + expect(eq(argv[2], plain)); +}}; // ─── The argv rules of CommandLineToArgvW (2026.10.5.2) ────────────────── // @@ -252,7 +250,7 @@ static std::vector argv_of(std::string_view line) { return out; } -TEST(WindowsCommandLine, EveryArgumentRoundTripsThroughTheArgvRules) { +UTest every_argument_round_trips_through_the_argv_rules{"WindowsCommandLine/EveryArgumentRoundTripsThroughTheArgvRules", [] { const std::vector args = { "plain", "C:\\Program Files (x86)\\Windows Kits\\10\\", // vcvars' WindowsSdkDir @@ -265,11 +263,11 @@ TEST(WindowsCommandLine, EveryArgumentRoundTripsThroughTheArgvRules) { }; std::string line; for (auto const& a : args) line += mcpp::build::command_line::quote_windows(a) + " "; - EXPECT_EQ(argv_of(line), args) << line; -} + expect(eq(argv_of(line), args)) << line; +}}; -TEST(WindowsCommandLine, AnArgumentEndingInABackslashDoesNotSwallowTheNext) { +UTest an_argument_ending_in_a_backslash_does_not_swallow_the_next{"WindowsCommandLine/AnArgumentEndingInABackslashDoesNotSwallowTheNext", [] { const auto line = mcpp::build::command_line::quote_windows("C:\\Kits\\10\\") + " " + mcpp::build::command_line::quote_windows("-o"); - EXPECT_EQ(argv_of(line), (std::vector{"C:\\Kits\\10\\", "-o"})) << line; -} + expect(eq(argv_of(line), (std::vector{"C:\\Kits\\10\\", "-o"}))) << line; +}}; diff --git a/tests/unit/test_windows_defaults.cpp b/tests/unit/test_windows_defaults.cpp index aa6d18905..843501563 100644 --- a/tests/unit/test_windows_defaults.cpp +++ b/tests/unit/test_windows_defaults.cpp @@ -1,12 +1,14 @@ -#include import std; +import mcpplibs.mtest; import mcpplibs.os.platform; import mcpp.toolchain.msvc; import mcpp.toolchain.triple; import mcpp.toolchain.registry; import mcpp.build.prepare; +using namespace mcpplibs::mtest; + // ── has_usable_msvc() ─────────────────────────────────────────────────────── // // The predicate that decides whether the MSVC ABI is a viable default on this @@ -14,22 +16,22 @@ import mcpp.build.prepare; // STL (Visual Studio's "Desktop development with C++" workload) nor the // Windows SDK, so a default that targets the MSVC ABI there can never build. -TEST(WindowsDefaults, HasUsableMsvcIsFalseOffWindows) { +UTest has_usable_msvc_is_false_off_windows{"WindowsDefaults/HasUsableMsvcIsFalseOffWindows", [] { if constexpr (mcpplibs::os::is_windows) { - GTEST_SKIP() << "windows behavior is covered by e2e 182"; + skip("windows behavior is covered by e2e 182"); } else { - EXPECT_FALSE(mcpp::toolchain::msvc::has_usable_msvc()); + expect(!mcpp::toolchain::msvc::has_usable_msvc()); } -} +}}; // The predicate must never disagree with the two probes it is defined as. // A drift here (e.g. someone "optimizing" it down to find_vs_install_path()) // silently reintroduces the half-installed-VS trap it exists to close. -TEST(WindowsDefaults, HasUsableMsvcAgreesWithItsParts) { +UTest has_usable_msvc_agrees_with_its_parts{"WindowsDefaults/HasUsableMsvcAgreesWithItsParts", [] { const bool both = mcpp::toolchain::msvc::find_std_module_source().has_value() && mcpp::toolchain::msvc::find_windows_sdk().has_value(); - EXPECT_EQ(mcpp::toolchain::msvc::has_usable_msvc(), both); -} + expect(eq(mcpp::toolchain::msvc::has_usable_msvc(), both)); +}}; // ── First-run pins ────────────────────────────────────────────────────────── // @@ -37,29 +39,29 @@ TEST(WindowsDefaults, HasUsableMsvcAgreesWithItsParts) { // ships no GCC, while on Windows clang targets the MSVC ABI and therefore // needs a Visual Studio that is not preinstalled. -TEST(WindowsDefaults, FirstRunPinsParse) { +UTest first_run_pins_parse{"WindowsDefaults/FirstRunPinsParse", [] { namespace pins = mcpp::toolchain::triple::pins; for (auto spec : { pins::kFirstRunMac, pins::kFirstRunWinMsvc, pins::kFirstRunWinGnu, pins::kFirstRunLinuxX86_64, pins::kFirstRunLinuxAarch64, pins::kFirstRunLinuxOther }) { auto parsed = mcpp::toolchain::parse_toolchain_spec(std::string(spec)); - ASSERT_TRUE(parsed.has_value()) << spec; - EXPECT_FALSE(parsed->version.empty()) << spec; + expect(fatal(parsed.has_value())) << spec; + expect(!(parsed->version.empty())) << spec; } -} +}}; // The GNU fallback must land on a target mcpp is willing to build for, and // the toolchain it names must equal that target's vocabulary pin — otherwise // the same decision is derived in two places and they will drift. -TEST(WindowsDefaults, GnuFallbackTargetIsVerifiedAndPinAgrees) { +UTest gnu_fallback_target_is_verified_and_pin_agrees{"WindowsDefaults/GnuFallbackTargetIsVerifiedAndPinAgrees", [] { namespace triple = mcpp::toolchain::triple; auto t = triple::parse(std::string(triple::pins::kFirstRunWinGnuTarget)); - ASSERT_TRUE(t.has_value()); + expect(fatal(t.has_value())); const auto* known = triple::find_known_target(*t); - ASSERT_NE(known, nullptr) << triple::pins::kFirstRunWinGnuTarget; - EXPECT_EQ(known->tier, "verified"); - EXPECT_EQ(known->pin, triple::pins::kFirstRunWinGnu); -} + expect(fatal(ne(known, nullptr))) << triple::pins::kFirstRunWinGnuTarget; + expect(eq(known->tier, "verified")); + expect(eq(known->pin, triple::pins::kFirstRunWinGnu)); +}}; // ── Intent classification ─────────────────────────────────────────────────── // @@ -67,19 +69,19 @@ TEST(WindowsDefaults, GnuFallbackTargetIsVerifiedAndPinAgrees) { // directions: call an explicit choice "mcpp's own" and a project that needs // the MSVC ABI silently gets a different one; call mcpp's own default // "explicit" and every user carrying a stale persisted default stays stuck. -TEST(WindowsDefaults, OriginClassification) { +UTest origin_classification{"WindowsDefaults/OriginClassification", [] { using mcpp::build::TcOrigin; using mcpp::build::tc_origin_is_user_explicit; - EXPECT_TRUE (tc_origin_is_user_explicit(TcOrigin::ManifestToolchain)); - EXPECT_TRUE (tc_origin_is_user_explicit(TcOrigin::TargetSection)); - EXPECT_FALSE(tc_origin_is_user_explicit(TcOrigin::GlobalDefault)); - EXPECT_FALSE(tc_origin_is_user_explicit(TcOrigin::TargetPin)); - EXPECT_FALSE(tc_origin_is_user_explicit(TcOrigin::FirstRun)); - EXPECT_FALSE(tc_origin_is_user_explicit(TcOrigin::None)); + expect(tc_origin_is_user_explicit(TcOrigin::ManifestToolchain)); + expect(tc_origin_is_user_explicit(TcOrigin::TargetSection)); + expect(!tc_origin_is_user_explicit(TcOrigin::GlobalDefault)); + expect(!tc_origin_is_user_explicit(TcOrigin::TargetPin)); + expect(!tc_origin_is_user_explicit(TcOrigin::FirstRun)); + expect(!tc_origin_is_user_explicit(TcOrigin::None)); // A compiler the dependency graph required is not the user's word either — // mcpp derived it, so the two paths that revise mcpp's own answers may. - EXPECT_FALSE(tc_origin_is_user_explicit(TcOrigin::GraphRequirement)); -} + expect(!tc_origin_is_user_explicit(TcOrigin::GraphRequirement)); +}}; // AND IT IS THE ONE ORIGIN THAT MAY NEVER BECOME THE MACHINE'S DEFAULT. // @@ -93,55 +95,55 @@ TEST(WindowsDefaults, OriginClassification) { // WINDOWS MACHINE. The e2e that checks `config.toml`'s sha256 runs where a // toolchain is already configured, so it never reaches either branch; here the // rule itself is the subject. -TEST(WindowsDefaults, OnlyMcppsOwnAnswersMayBePersisted) { +UTest only_mcpps_own_answers_may_be_persisted{"WindowsDefaults/OnlyMcppsOwnAnswersMayBePersisted", [] { using mcpp::build::TcOrigin; using mcpp::build::tc_origin_may_persist; - EXPECT_FALSE(tc_origin_may_persist(TcOrigin::GraphRequirement)); - EXPECT_TRUE (tc_origin_may_persist(TcOrigin::GlobalDefault)); - EXPECT_TRUE (tc_origin_may_persist(TcOrigin::TargetPin)); - EXPECT_TRUE (tc_origin_may_persist(TcOrigin::FirstRun)); - EXPECT_TRUE (tc_origin_may_persist(TcOrigin::None)); -} + expect(!tc_origin_may_persist(TcOrigin::GraphRequirement)); + expect(bool(tc_origin_may_persist(TcOrigin::GlobalDefault))); + expect(bool(tc_origin_may_persist(TcOrigin::TargetPin))); + expect(bool(tc_origin_may_persist(TcOrigin::FirstRun))); + expect(bool(tc_origin_may_persist(TcOrigin::None))); +}}; // The fallback target must be PE/GNU, not the host's MSVC-ABI triple — the // whole point is to leave the ABI that needs Visual Studio behind. -TEST(WindowsDefaults, GnuFallbackTargetIsWindowsGnu) { +UTest gnu_fallback_target_is_windows_gnu{"WindowsDefaults/GnuFallbackTargetIsWindowsGnu", [] { namespace triple = mcpp::toolchain::triple; auto t = triple::parse(std::string(triple::pins::kFirstRunWinGnuTarget)); - ASSERT_TRUE(t.has_value()); - EXPECT_EQ(t->os, "windows"); - EXPECT_EQ(t->env, "gnu"); - EXPECT_TRUE(t->is_windows_gnu()); -} + expect(fatal(t.has_value())); + expect(eq(t->os, "windows")); + expect(eq(t->env, "gnu")); + expect(t->is_windows_gnu()); +}}; -TEST(LinuxDefaults, NativeArm64UsesLlvmAndOtherDefaultsRemainStable) { +UTest native_arm64_uses_llvm_and_other_defaults_remain_stable{"LinuxDefaults/NativeArm64UsesLlvmAndOtherDefaultsRemainStable", [] { namespace pins = mcpp::toolchain::triple::pins; - EXPECT_EQ(pins::linux_default_toolchain("aarch64"), "llvm@23.1.3"); - EXPECT_EQ(pins::linux_default_toolchain("x86_64"), "gcc@16.1.0"); - EXPECT_EQ(pins::linux_default_toolchain("riscv64"), "gcc@15.1.0-musl"); + expect(eq(pins::linux_default_toolchain("aarch64"), "llvm@23.1.3")); + expect(eq(pins::linux_default_toolchain("x86_64"), "gcc@16.1.0")); + expect(eq(pins::linux_default_toolchain("riscv64"), "gcc@15.1.0-musl")); auto target = mcpp::toolchain::triple::parse("aarch64-linux-gnu"); - ASSERT_TRUE(target); + expect(fatal(bool(target))); auto row = mcpp::toolchain::triple::find_known_target(*target); - ASSERT_TRUE(row); - EXPECT_EQ(row->pin, pins::kFirstRunLinuxAarch64); - EXPECT_FALSE(target->pin_is_capability()); -} + expect(fatal(bool(row))); + expect(eq(row->pin, pins::kFirstRunLinuxAarch64)); + expect(!target->pin_is_capability()); +}}; // A Luban system's /etc/mcpp/config.toml names its own gcc on the host's // triple (gcc@16.1.0 + aarch64-linux-gnu); the aarch64-linux-gnu row's llvm // convention replaced it with a download (xim-pkgindex#986, editions CI on // arm). The configured pair for the host's own target is kept; a cross target // and a remembered default without a target are not this case. -TEST(LinuxDefaults, AConfiguredPairForTheHostsOwnTargetKeepsItsToolchain) { +UTest a_configured_pair_for_the_hosts_own_target_keeps_its_toolchain{"LinuxDefaults/AConfiguredPairForTheHostsOwnTargetKeepsItsToolchain", [] { namespace tr = mcpp::toolchain::triple; using mcpp::build::TcOrigin; using mcpp::build::configured_native_pair; auto arm = tr::parse("aarch64-linux-gnu"); auto x86 = tr::parse("x86_64-linux-gnu"); auto winGnu = tr::parse("x86_64-windows-gnu"); - ASSERT_TRUE(arm && x86 && winGnu); - EXPECT_TRUE(configured_native_pair(true, TcOrigin::GlobalDefault, *arm, *arm)); - EXPECT_FALSE(configured_native_pair(false, TcOrigin::GlobalDefault, *arm, *arm)) << "--target names it"; - EXPECT_FALSE(configured_native_pair(true, TcOrigin::FirstRun, *arm, *arm)) << "mcpp's own guess"; - EXPECT_FALSE(configured_native_pair(true, TcOrigin::GlobalDefault, *winGnu, *x86)) << "a cross target"; -} + expect(fatal(arm && x86 && winGnu)); + expect(bool(configured_native_pair(true, TcOrigin::GlobalDefault, *arm, *arm))); + expect(!configured_native_pair(false, TcOrigin::GlobalDefault, *arm, *arm)) << "--target names it"; + expect(!configured_native_pair(true, TcOrigin::FirstRun, *arm, *arm)) << "mcpp's own guess"; + expect(!configured_native_pair(true, TcOrigin::GlobalDefault, *winGnu, *x86)) << "a cross target"; +}}; diff --git a/tests/unit/test_wire.cpp b/tests/unit/test_wire.cpp index 87d53dab6..b8995daa8 100644 --- a/tests/unit/test_wire.cpp +++ b/tests/unit/test_wire.cpp @@ -9,12 +9,14 @@ // // Design: .agents/docs/2026-08-08-machine-readable-output-protocol-design.md -#include import std; +import mcpplibs.mtest; import mcpp.wire; import nlohmann.json; +using namespace mcpplibs::mtest; + namespace w = mcpp::wire; namespace { @@ -24,48 +26,48 @@ namespace { // the entry point: on older versions it is itself an unknown option. So the // envelope must be self-identifying, and these two keys are what identifies // it. -TEST(WireEnvelope, IsSelfIdentifying) { +UTest is_self_identifying{"WireEnvelope/IsSelfIdentifying", [] { auto j = w::to_json(w::Envelope{.kind = "mcpp.env"}); - ASSERT_TRUE(j.contains("schemaVersion")) << "a client detects by parsing"; - ASSERT_TRUE(j.contains("kind")); - EXPECT_EQ(j["schemaVersion"], w::kEnvelopeVersion); - EXPECT_EQ(j["kind"], "mcpp.env"); -} + expect(fatal(j.contains("schemaVersion"))) << "a client detects by parsing"; + expect(fatal(j.contains("kind"))); + expect(eq(j["schemaVersion"], w::kEnvelopeVersion)); + expect(eq(j["kind"], "mcpp.env")); +}}; -TEST(WireEnvelope, CarriesTheKindsOwnVersionSeparately) { +UTest carries_the_kinds_own_version_separately{"WireEnvelope/CarriesTheKindsOwnVersionSeparately", [] { auto j = w::to_json(w::Envelope{.kind = "mcpp.xpkg"}); // Separate from schemaVersion on purpose: one global number would make a // field added to mcpp.env move the version a client reads for mcpp.xpkg, // with no way to tell which actually changed. - ASSERT_TRUE(j.contains("kindVersion")); - EXPECT_EQ(j["kindVersion"], 1); -} + expect(fatal(j.contains("kindVersion"))); + expect(eq(j["kindVersion"], 1)); +}}; -TEST(WireEnvelope, AnUnknownKindHasNoVersion) { +UTest an_unknown_kind_has_no_version{"WireEnvelope/AnUnknownKindHasNoVersion", [] { auto j = w::to_json(w::Envelope{.kind = "mcpp.not-a-kind"}); - EXPECT_EQ(j["kindVersion"], 0) << "silently claiming version 1 would be a lie"; -} + expect(eq(j["kindVersion"], 0)) << "silently claiming version 1 would be a lie"; +}}; // Effects are named, not boolean. `init-mcpp-home` is something a client may // ignore; `exec-build-script` runs code out of the workspace and is the whole // reason an untrusted-workspace gate exists. One bool cannot separate them. -TEST(WireEnvelope, EffectsAreNamed) { +UTest effects_are_named{"WireEnvelope/EffectsAreNamed", [] { auto j = w::to_json(w::Envelope{ .kind = "mcpp.env", .effects = {w::Effect::InitMcppHome, w::Effect::ExecBuildScript}}); - ASSERT_EQ(j["effects"].size(), 2u); - EXPECT_EQ(j["effects"][0], "init-mcpp-home"); - EXPECT_EQ(j["effects"][1], "exec-build-script"); -} + expect(fatal(eq(j["effects"].size(), 2u))); + expect(eq(j["effects"][0], "init-mcpp-home")); + expect(eq(j["effects"][1], "exec-build-script")); +}}; -TEST(WireEnvelope, NoEffectsIsAnEmptyArrayNotAbsent) { +UTest no_effects_is_an_empty_array_not_absent{"WireEnvelope/NoEffectsIsAnEmptyArrayNotAbsent", [] { auto j = w::to_json(w::Envelope{.kind = "mcpp.xpkg"}); - ASSERT_TRUE(j.contains("effects")); - EXPECT_TRUE(j["effects"].is_array()); - EXPECT_TRUE(j["effects"].empty()) << "absent would mean 'unknown', not 'none'"; -} + expect(fatal(j.contains("effects"))); + expect(j["effects"].is_array()); + expect(j["effects"].empty()) << "absent would mean 'unknown', not 'none'"; +}}; -TEST(WireEnvelope, DiagnosticsCarryCodeAndPosition) { +UTest diagnostics_carry_code_and_position{"WireEnvelope/DiagnosticsCarryCodeAndPosition", [] { w::Diagnostic d{ .code = "MCPP_MANIFEST_UNKNOWN_KEY", .severity = w::Severity::Warning, @@ -74,112 +76,112 @@ TEST(WireEnvelope, DiagnosticsCarryCodeAndPosition) { .range = w::Range{{3, 1}, {3, 9}}, }; auto j = w::to_json(w::Envelope{.kind = "mcpp.env", .diagnostics = {d}}); - ASSERT_EQ(j["diagnostics"].size(), 1u); + expect(fatal(eq(j["diagnostics"].size(), 1u))); auto const& g = j["diagnostics"][0]; - EXPECT_EQ(g["code"], "MCPP_MANIFEST_UNKNOWN_KEY"); - EXPECT_EQ(g["severity"], "warning"); - EXPECT_EQ(g["source"], "mcpp"); - EXPECT_EQ(g["path"], "mcpp.toml"); - EXPECT_EQ(g["range"]["start"]["line"], 3); - EXPECT_EQ(g["range"]["end"]["column"], 9); -} + expect(eq(g["code"], "MCPP_MANIFEST_UNKNOWN_KEY")); + expect(eq(g["severity"], "warning")); + expect(eq(g["source"], "mcpp")); + expect(eq(g["path"], "mcpp.toml")); + expect(eq(g["range"]["start"]["line"], 3)); + expect(eq(g["range"]["end"]["column"], 9)); +}}; // A diagnostic without a location must not invent one: absent keys, not // zeros. `line: 0` would send a client to a position that does not exist. -TEST(WireEnvelope, ADiagnosticWithoutALocationOmitsIt) { +UTest a_diagnostic_without_a_location_omits_it{"WireEnvelope/ADiagnosticWithoutALocationOmitsIt", [] { auto j = w::to_json(w::Envelope{ .kind = "mcpp.env", .diagnostics = {{.code = "MCPP_X", .message = "no place"}}}); auto const& g = j["diagnostics"][0]; - EXPECT_FALSE(g.contains("path")); - EXPECT_FALSE(g.contains("range")); -} + expect(!g.contains("path")); + expect(!g.contains("range")); +}}; -TEST(WireEnvelope, ReportsMcppVersionAndProtocolWindow) { +UTest reports_mcpp_version_and_protocol_window{"WireEnvelope/ReportsMcppVersionAndProtocolWindow", [] { auto j = w::to_json(w::Envelope{.kind = "mcpp.env"}); - EXPECT_FALSE(j["mcpp"]["version"].get().empty()); - EXPECT_EQ(j["mcpp"]["protocol"]["min"], w::kEnvelopeVersion); - EXPECT_EQ(j["mcpp"]["protocol"]["max"], w::kEnvelopeVersion); -} + expect(!(j["mcpp"]["version"].get().empty())); + expect(eq(j["mcpp"]["protocol"]["min"], w::kEnvelopeVersion)); + expect(eq(j["mcpp"]["protocol"]["max"], w::kEnvelopeVersion)); +}}; // Every envelope carries the features beside the version, so a client's one // `self env` (or any job's envelope) says what the project's mcpp can do // (design 2026-10-30 part 2 §4.3); the list is the protocol document's. -TEST(WireEnvelope, CarriesTheFeaturesOfTheProtocolDocument) { +UTest carries_the_features_of_the_protocol_document{"WireEnvelope/CarriesTheFeaturesOfTheProtocolDocument", [] { auto j = w::to_json(w::Envelope{.kind = "mcpp.env"}); - ASSERT_TRUE(j["mcpp"].contains("features") && j["mcpp"]["features"].is_array()); - EXPECT_EQ(j["mcpp"]["features"], w::protocol_document({})["features"]); -} + expect(fatal(j["mcpp"].contains("features") && j["mcpp"]["features"].is_array())); + expect(eq(j["mcpp"]["features"], w::protocol_document({})["features"])); +}}; // ── Negotiation ──────────────────────────────────────────────────────────── -TEST(WireProtocol, ListsKindsAndTheirVersions) { +UTest lists_kinds_and_their_versions{"WireProtocol/ListsKindsAndTheirVersions", [] { auto j = w::protocol_document({ {"self env", {w::Effect::InitMcppHome}}, {"xpkg parse", {}}, }); - ASSERT_TRUE(j.contains("kinds")); + expect(fatal(j.contains("kinds"))); // `{min,max}` alone cannot answer "do you speak mcpp.env, at what // version" — which is the question a client actually has. - EXPECT_EQ(j["kinds"]["mcpp.env"], 1); - EXPECT_EQ(j["kinds"]["mcpp.xpkg"], 1); -} + expect(eq(j["kinds"]["mcpp.env"], 1)); + expect(eq(j["kinds"]["mcpp.xpkg"], 1)); +}}; // The gate has to decide BEFORE running. By the time an envelope arrives, // whatever it describes has already happened — so the effects have to be // available statically too. -TEST(WireProtocol, DeclaresEffectsPerCommandStatically) { +UTest declares_effects_per_command_statically{"WireProtocol/DeclaresEffectsPerCommandStatically", [] { auto j = w::protocol_document({ {"self env", {w::Effect::InitMcppHome}}, {"xpkg parse", {}}, }); - ASSERT_TRUE(j["commands"].contains("self env")); - EXPECT_EQ(j["commands"]["self env"]["effects"][0], "init-mcpp-home"); - EXPECT_TRUE(j["commands"]["xpkg parse"]["effects"].empty()) + expect(fatal(j["commands"].contains("self env"))); + expect(eq(j["commands"]["self env"]["effects"][0], "init-mcpp-home")); + expect(j["commands"]["xpkg parse"]["effects"].empty()) << "measured on a fresh MCPP_HOME: it creates nothing"; -} +}}; // A client gates on a feature, not on a version number (design 2026-10-30 // part 2 §4.3): the list is present, and the names are the stable ones. -TEST(WireProtocol, NamesTheFeaturesAClientGatesOn) { +UTest names_the_features_a_client_gates_on{"WireProtocol/NamesTheFeaturesAClientGatesOn", [] { auto j = w::protocol_document({}); - ASSERT_TRUE(j.contains("features") && j["features"].is_array()); + expect(fatal(j.contains("features") && j["features"].is_array())); std::vector names; for (auto const& f : j["features"]) names.push_back(f.get()); - EXPECT_NE(std::ranges::find(names, "target-sets"), names.end()); - EXPECT_NE(std::ranges::find(names, "pkg-format-list"), names.end()); - EXPECT_NE(std::ranges::find(names, "test-launch"), names.end()); - EXPECT_NE(std::ranges::find(names, "launch-stream"), names.end()); - EXPECT_NE(std::ranges::find(names, "test-protocol"), names.end()); -} - -TEST(WireProtocol, IsItselfSelfIdentifying) { + expect(ne(std::ranges::find(names, "target-sets"), names.end())); + expect(ne(std::ranges::find(names, "pkg-format-list"), names.end())); + expect(ne(std::ranges::find(names, "test-launch"), names.end())); + expect(ne(std::ranges::find(names, "launch-stream"), names.end())); + expect(ne(std::ranges::find(names, "test-protocol"), names.end())); +}}; + +UTest is_itself_self_identifying{"WireProtocol/IsItselfSelfIdentifying", [] { auto j = w::protocol_document({}); - EXPECT_EQ(j["kind"], "mcpp.protocol"); - EXPECT_TRUE(j.contains("schemaVersion")); -} + expect(eq(j["kind"], "mcpp.protocol")); + expect(j.contains("schemaVersion")); +}}; // ── Format selection ─────────────────────────────────────────────────────── -TEST(WireFormat, KnowsJsonAndRefusesTheRest) { - EXPECT_EQ(w::parse_format("json"), w::Format::Json); - EXPECT_FALSE(w::parse_format("yaml").has_value()); - EXPECT_FALSE(w::parse_format("ndjson").has_value()) << "reserved, not supported"; - EXPECT_FALSE(w::parse_format("").has_value()); -} +UTest knows_json_and_refuses_the_rest{"WireFormat/KnowsJsonAndRefusesTheRest", [] { + expect(eq(w::parse_format("json"), w::Format::Json)); + expect(!(w::parse_format("yaml").has_value())); + expect(!(w::parse_format("ndjson").has_value())) << "reserved, not supported"; + expect(!(w::parse_format("").has_value())); +}}; -TEST(WireFormat, TheRefusalNamesWhatIsSupported) { +UTest the_refusal_names_what_is_supported{"WireFormat/TheRefusalNamesWhatIsSupported", [] { auto m = w::unsupported_format("yaml"); - EXPECT_NE(m.find("yaml"), std::string::npos); - EXPECT_NE(m.find("json"), std::string::npos) << "tell the client what to ask for"; -} + expect(ne(m.find("yaml"), std::string::npos)); + expect(ne(m.find("json"), std::string::npos)) << "tell the client what to ask for"; +}}; // LegacyJson is a distinct format, not a synonym. `--json` has shipped with a // bare payload (`cache list --json` is `{root, entries}` at the top level, and // this repo's e2e asserts those keys); enveloping it would break consumers. -TEST(WireFormat, LegacyJsonIsNotTheSameFormatAsJson) { - EXPECT_NE(w::Format::LegacyJson, w::Format::Json); -} +UTest legacy_json_is_not_the_same_format_as_json{"WireFormat/LegacyJsonIsNotTheSameFormatAsJson", [] { + expect(ne(w::Format::LegacyJson, w::Format::Json)); +}}; // ── Golden shapes, one per kind ──────────────────────────────────────────── // @@ -198,7 +200,7 @@ void expect_has_keys(const nlohmann::json& obj, std::initializer_list keys, std::string_view what) { for (auto k : keys) - EXPECT_TRUE(obj.contains(k)) + expect(obj.contains(k)) << what << " lost published key '" << k << "'. Removing or renaming one is a breaking change: bump the " "kind's version in mcpp.wire and say so in " @@ -207,15 +209,15 @@ void expect_has_keys(const nlohmann::json& obj, } // namespace -TEST(WireGolden, EnvelopeKeySet) { +UTest envelope_key_set{"WireGolden/EnvelopeKeySet", [] { auto j = w::to_json(w::Envelope{.kind = "mcpp.env"}); expect_has_keys(j, {"schemaVersion", "kind", "kindVersion", "effects", "mcpp", "data", "diagnostics"}, "the envelope"); expect_has_keys(j["mcpp"], {"version", "protocol", "features"}, "envelope.mcpp"); expect_has_keys(j["mcpp"]["protocol"], {"min", "max"}, "envelope.mcpp.protocol"); -} +}}; -TEST(WireGolden, DiagnosticKeySet) { +UTest diagnostic_key_set{"WireGolden/DiagnosticKeySet", [] { auto j = w::to_json(w::Envelope{ .kind = "mcpp.env", .diagnostics = {{.code = "C", .message = "m", .path = "p", @@ -226,19 +228,19 @@ TEST(WireGolden, DiagnosticKeySet) { expect_has_keys(j["diagnostics"][0]["range"], {"start", "end"}, "a range"); expect_has_keys(j["diagnostics"][0]["range"]["start"], {"line", "column"}, "a position"); -} +}}; -TEST(WireGolden, ProtocolDocumentKeySet) { +UTest protocol_document_key_set{"WireGolden/ProtocolDocumentKeySet", [] { auto j = w::protocol_document({{"self env", {w::Effect::InitMcppHome}}}); expect_has_keys(j, {"schemaVersion", "kind", "mcpp", "envelope", "kinds", "commands", "features"}, "the protocol document"); expect_has_keys(j["envelope"], {"min", "max"}, "protocol.envelope"); expect_has_keys(j["commands"]["self env"], {"effects"}, "a command entry"); -} +}}; // Every effect name is part of the contract: a client matches on these // strings, so renaming one silently changes what a gate lets through. -TEST(WireGolden, EffectNamesAreStable) { +UTest effect_names_are_stable{"WireGolden/EffectNamesAreStable", [] { struct { w::Effect e; const char* name; } const expected[]{ {w::Effect::InitMcppHome, "init-mcpp-home"}, {w::Effect::ReadProject, "read-project"}, @@ -248,41 +250,41 @@ TEST(WireGolden, EffectNamesAreStable) { {w::Effect::ExecBuildScript, "exec-build-script"}, }; for (auto const& x : expected) - EXPECT_EQ(w::effect_name(x.e), x.name) + expect(eq(w::effect_name(x.e), x.name)) << "effect names are matched by clients; renaming one changes " "what an untrusted-workspace gate admits"; -} +}}; -TEST(WireGolden, SeverityNamesAreStable) { - EXPECT_EQ(w::severity_name(w::Severity::Error), "error"); - EXPECT_EQ(w::severity_name(w::Severity::Warning), "warning"); - EXPECT_EQ(w::severity_name(w::Severity::Note), "note"); -} +UTest severity_names_are_stable{"WireGolden/SeverityNamesAreStable", [] { + expect(eq(w::severity_name(w::Severity::Error), "error")); + expect(eq(w::severity_name(w::Severity::Warning), "warning")); + expect(eq(w::severity_name(w::Severity::Note), "note")); +}}; // The kinds this build claims to speak. A client reads this list to decide // whether to bother calling; dropping one silently is a breaking change. -TEST(WireGolden, DeclaredKinds) { +UTest declared_kinds{"WireGolden/DeclaredKinds", [] { auto j = w::protocol_document({}); expect_has_keys(j["kinds"], {"mcpp.env", "mcpp.xpkg", "mcpp.cache", "mcpp.build-database"}, "the kind list"); - EXPECT_EQ(j["kinds"]["mcpp.build-database"], 1); -} + expect(eq(j["kinds"]["mcpp.build-database"], 1)); +}}; // A failure that leaves nothing to describe omits `data`: a consumer of the // build database reads "no data" as "the command failed" (S2 0.2.0 section 3.4), // so an empty object in its place would read as a success with nothing in it. -TEST(WireGolden, NullDataIsOmitted) { +UTest null_data_is_omitted{"WireGolden/NullDataIsOmitted", [] { auto failed = w::to_json(w::Envelope{ .kind = "mcpp.build-database", .effects = {w::Effect::ReadProject}, .data = nullptr, .diagnostics = {{"MCPP_BUILD_DATABASE_PLAN_FAILED", w::Severity::Error, "x"}}, }); - EXPECT_FALSE(failed.contains("data")) << failed.dump(); - EXPECT_TRUE(failed.contains("diagnostics")); + expect(!failed.contains("data")) << failed.dump(); + expect(failed.contains("diagnostics")); auto ok = w::to_json(w::Envelope{.kind = "mcpp.env"}); - EXPECT_TRUE(ok.contains("data")) << ok.dump(); -} + expect(ok.contains("data")) << ok.dump(); +}}; } // namespace diff --git a/tests/unit/test_workspace_inheritance.cpp b/tests/unit/test_workspace_inheritance.cpp index a22ae5249..fd1e52124 100644 --- a/tests/unit/test_workspace_inheritance.cpp +++ b/tests/unit/test_workspace_inheritance.cpp @@ -1,10 +1,13 @@ -#include import std; +import mcpplibs.mtest; +import mcpplibs.os.stdio; import mcpp.manifest; import mcpp.project; import mcpp.build.prepare; +using namespace mcpplibs::mtest; + // #690. The unit-level halves of the workspace-inheritance repair: the // `[workspace.build]` key table (one statement of the inheritable subset), the // keyed `defines` fold, and the snapshot's post-condition. The end-to-end @@ -53,133 +56,131 @@ std::vector as_strings(const std::vector& v) // F3: a key the parser assigns and inherits must also be a key it accepts. // `ios_deployment_target` was the counterexample. -TEST(WorkspaceInheritance, EveryTableRowIsParsedAndInherited) { +UTest every_table_row_is_parsed_and_inherited{"WorkspaceInheritance/EveryTableRowIsParsedAndInherited", [] { auto ws = mcpp::manifest::parse_string(workspace_declaring_every_key()); - ASSERT_TRUE(ws.has_value()) << ws.error().format(); - ASSERT_TRUE(ws->workspace.inherited.buildPresent); + if (auto&& failed = expect(fatal(ws.has_value())); !failed) failed << ws.error().format(); + expect(fatal(bool(ws->workspace.inherited.buildPresent))); auto member = mcpp::manifest::parse_string( "[package]\nname = \"m\"\n", "m/mcpp.toml", {.insideWorkspace = true}); - ASSERT_TRUE(member.has_value()) << member.error().format(); + if (auto&& failed = expect(fatal(member.has_value())); !failed) failed << member.error().format(); const std::filesystem::path wsRoot = "/ws"; mcpp::project::inherit_workspace_build(*member, *ws, wsRoot); std::size_t scalars = 0; for (auto const& row : mcpp::manifest::kWorkspaceBuildKeys) { - SCOPED_TRACE(std::string(row.key)); + /* trace: std::string(row.key) */; auto const& parsed = ws->workspace.inherited.build; auto const& inherited = member->buildConfig; if (auto const* f = std::get_if mcpp::manifest::BuildConfig::*>(&row.field)) { const std::vector want{std::format("ws_{}", row.key)}; - EXPECT_EQ(parsed.**f, want); - EXPECT_EQ(inherited.**f, want); + expect(eq(parsed.**f, want)); + expect(eq(inherited.**f, want)); } else if (auto const* f = std::get_if mcpp::manifest::BuildConfig::*>(&row.field)) { - EXPECT_EQ(as_strings(parsed.**f), - std::vector{std::format("ws_{}", row.key)}); + expect(eq(as_strings(parsed.**f), std::vector{std::format("ws_{}", row.key)})); // Relative include directories are anchored at the workspace root. - EXPECT_EQ(as_strings(inherited.**f), - std::vector{ - (wsRoot / std::format("ws_{}", row.key)).generic_string()}); + expect(eq(as_strings(inherited.**f), std::vector{ + (wsRoot / std::format("ws_{}", row.key)).generic_string()})); } else if (auto const* f = std::get_if(&row.field)) { ++scalars; auto it = kSampleScalar.find(std::string(row.key)); - ASSERT_NE(it, kSampleScalar.end()) << "no sample value for a new scalar row"; - EXPECT_EQ(parsed.**f, it->second); - EXPECT_EQ(inherited.**f, it->second); + expect(fatal(ne(it, kSampleScalar.end()))) << "no sample value for a new scalar row"; + expect(eq(parsed.**f, it->second)); + expect(eq(inherited.**f, it->second)); } } - EXPECT_EQ(scalars, kSampleScalar.size()); -} + expect(eq(scalars, kSampleScalar.size())); +}}; -TEST(WorkspaceInheritance, UnknownKeyErrorListsTheTable) { +UTest unknown_key_error_lists_the_table{"WorkspaceInheritance/UnknownKeyErrorListsTheTable", [] { auto ws = mcpp::manifest::parse_string( "[workspace]\nmembers = [\"m\"]\n\n[workspace.build]\nno_such_key = []\n"); - ASSERT_FALSE(ws.has_value()); + expect(fatal(!ws.has_value())); const auto text = ws.error().format(); for (auto const& row : mcpp::manifest::kWorkspaceBuildKeys) - EXPECT_NE(text.find(row.key), std::string::npos) << row.key; -} + expect(ne(text.find(row.key), std::string::npos)) << row.key; +}}; // W7: `defines` is a set keyed by macro name. -TEST(DefinesFold, LaterEntryReplacesEarlierInPlace) { +UTest later_entry_replaces_earlier_in_place{"DefinesFold/LaterEntryReplacesEarlierInPlace", [] { mcpp::manifest::BuildConfig bc; bc.defines = {"X=1", "Y", "X=2"}; mcpp::build::fold_build_defines_into_flags(bc); - EXPECT_EQ(bc.cxxflags, (std::vector{"-DX=2", "-DY"})); - EXPECT_EQ(bc.cflags, (std::vector{"-DX=2", "-DY"})); - EXPECT_TRUE(bc.defines.empty()); -} + expect(eq(bc.cxxflags, (std::vector{"-DX=2", "-DY"}))); + expect(eq(bc.cflags, (std::vector{"-DX=2", "-DY"}))); + expect(bc.defines.empty()); +}}; -TEST(DefinesFold, BangNameRemovesTheName) { +UTest bang_name_removes_the_name{"DefinesFold/BangNameRemovesTheName", [] { mcpp::manifest::BuildConfig bc; bc.defines = {"X=1", "!X", "Z"}; mcpp::build::fold_build_defines_into_flags(bc); - EXPECT_EQ(bc.cxxflags, (std::vector{"-DZ"})); -} + expect(eq(bc.cxxflags, (std::vector{"-DZ"}))); +}}; -TEST(DefinesFold, EntrySupersedesADashDWordInTheFlagLists) { +UTest entry_supersedes_a_dash_d_word_in_the_flag_lists{"DefinesFold/EntrySupersedesADashDWordInTheFlagLists", [] { mcpp::manifest::BuildConfig bc; bc.cxxflags = {"-DX=1", "-O2", "-DXY=1"}; bc.cflags = {"-DX"}; bc.defines = {"X=3"}; mcpp::build::fold_build_defines_into_flags(bc); // `-DXY` is another name and stays; `-DX=1` and `-DX` are superseded. - EXPECT_EQ(bc.cxxflags, (std::vector{"-O2", "-DXY=1", "-DX=3"})); - EXPECT_EQ(bc.cflags, (std::vector{"-DX=3"})); -} + expect(eq(bc.cxxflags, (std::vector{"-O2", "-DXY=1", "-DX=3"}))); + expect(eq(bc.cflags, (std::vector{"-DX=3"}))); +}}; // The layer-conditional pass folds a second time with only its own entries. -TEST(DefinesFold, SecondPassRemovesAWordTheFirstPassFolded) { +UTest second_pass_removes_a_word_the_first_pass_folded{"DefinesFold/SecondPassRemovesAWordTheFirstPassFolded", [] { mcpp::manifest::BuildConfig bc; bc.defines = {"X=1", "W"}; mcpp::build::fold_build_defines_into_flags(bc); bc.defines = {"!X"}; mcpp::build::fold_build_defines_into_flags(bc); - EXPECT_EQ(bc.cxxflags, (std::vector{"-DW"})); -} + expect(eq(bc.cxxflags, (std::vector{"-DW"}))); +}}; -TEST(DefinesFold, ValueWithSpaceStaysOneWordAndKeyed) { +UTest value_with_space_stays_one_word_and_keyed{"DefinesFold/ValueWithSpaceStaysOneWordAndKeyed", [] { mcpp::manifest::BuildConfig bc; bc.defines = {"N=\"a b\""}; mcpp::build::fold_build_defines_into_flags(bc); - ASSERT_EQ(bc.cxxflags.size(), 1u); + expect(fatal(eq(bc.cxxflags.size(), 1u))); bc.defines = {"N=2"}; mcpp::build::fold_build_defines_into_flags(bc); - EXPECT_EQ(bc.cxxflags, (std::vector{"-DN=2"})); -} + expect(eq(bc.cxxflags, (std::vector{"-DN=2"}))); +}}; // W1: the snapshot refuses a manifest whose `defines` were not folded. -TEST(SnapshotPostcondition, UnfoldedDefinesAreAnInternalError) { +UTest unfolded_defines_are_an_internal_error{"SnapshotPostcondition/UnfoldedDefinesAreAnInternalError", [] { mcpp::manifest::Manifest m; m.package.name = "lib"; - EXPECT_FALSE(mcpp::build::unfolded_defines_error(m).has_value()); + expect(!(mcpp::build::unfolded_defines_error(m).has_value())); m.buildConfig.defines = {"WORKSPACE_DEFINE=1"}; auto err = mcpp::build::unfolded_defines_error(m); - ASSERT_TRUE(err.has_value()); - EXPECT_NE(err->find("internal error"), std::string::npos); - EXPECT_NE(err->find("'lib'"), std::string::npos); - EXPECT_NE(err->find("WORKSPACE_DEFINE=1"), std::string::npos); + expect(fatal(err.has_value())); + expect(ne(err->find("internal error"), std::string::npos)); + expect(ne(err->find("'lib'"), std::string::npos)); + expect(ne(err->find("WORKSPACE_DEFINE=1"), std::string::npos)); mcpp::build::fold_build_defines_into_flags(m.buildConfig); - EXPECT_FALSE(mcpp::build::unfolded_defines_error(m).has_value()); -} + expect(!(mcpp::build::unfolded_defines_error(m).has_value())); +}}; // #713. A member inherits the workspace root's `[xlings.workspace]` entries, // conditional rows included; a package the member declares itself keeps the // member's address, because the nearer declaration wins (SPEC-004 §4.5). -TEST(WorkspaceXlings, AMemberInheritsTheRootsEntriesAndItsOwnWins) { +UTest a_member_inherits_the_roots_entries_and_its_own_wins{"WorkspaceXlings/AMemberInheritsTheRootsEntriesAndItsOwnWins", [] { auto ws = mcpp::manifest::parse_string( "[workspace]\nmembers = [\"m\"]\n\n" "[xlings.workspace]\nninja = \"1.12.1\"\ncmake = \"3.30.0\"\n\n" "[target.'cfg(os = \"linux\")'.xlings.workspace]\npatchelf = \"0.18.0\"\n"); - ASSERT_TRUE(ws.has_value()) << ws.error().format(); + if (auto&& failed = expect(fatal(ws.has_value())); !failed) failed << ws.error().format(); auto member = mcpp::manifest::parse_string( "[package]\nname = \"m\"\nversion = \"0.1.0\"\n\n" "[xlings.workspace]\ncmake = \"3.31.0\"\n", "m/mcpp.toml", {.insideWorkspace = true}); - ASSERT_TRUE(member.has_value()) << member.error().format(); + if (auto&& failed = expect(fatal(member.has_value())); !failed) failed << member.error().format(); mcpp::project::inherit_workspace_xlings(*member, *ws); @@ -188,41 +189,41 @@ TEST(WorkspaceXlings, AMemberInheritsTheRootsEntriesAndItsOwnWins) { return a.find(needle) != std::string::npos; }); }; - EXPECT_TRUE(has("ninja@1.12.1")); - EXPECT_TRUE(has("cmake@3.31.0")); - EXPECT_FALSE(has("cmake@3.30.0")); + expect(bool(has("ninja@1.12.1"))); + expect(bool(has("cmake@3.31.0"))); + expect(!has("cmake@3.30.0")); // The conditional row travels as a row, decided by its selector at merge time. bool rowCarried = false; for (auto const& cc : member->conditionalConfigs) for (auto const& a : cc.xlings.deps) rowCarried = rowCarried || a.find("patchelf@0.18.0") != std::string::npos; - EXPECT_TRUE(rowCarried); -} + expect(bool(rowCarried)); +}}; // #714. An entry that says `workspace = true` and that no workspace resolved is // refused by name, in every dependency table. -TEST(WorkspaceDependency, AnUnresolvedWorkspaceEntryIsNamed) { +UTest an_unresolved_workspace_entry_is_named{"WorkspaceDependency/AnUnresolvedWorkspaceEntryIsNamed", [] { for (std::string_view table : {"dependencies", "dev-dependencies", "build-dependencies"}) { - SCOPED_TRACE(std::string(table)); + /* trace: std::string(table) */; auto m = mcpp::manifest::parse_string(std::format( "[package]\nname = \"m\"\nversion = \"0.1.0\"\n\n[{}]\nfmt = {{ workspace = true }}\n", table), "m/mcpp.toml", {.insideWorkspace = true}); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto err = mcpp::project::unresolved_workspace_dependency_error(*m, "/p/m"); - ASSERT_TRUE(err.has_value()); - EXPECT_NE(err->find(std::format("[{}] fmt", table)), std::string::npos) << *err; - EXPECT_NE(err->find("members"), std::string::npos) << *err; + expect(fatal(err.has_value())); + if (auto&& failed = expect(ne(err->find(std::format("[{}] fmt", table)), std::string::npos)); !failed) failed << *err; + if (auto&& failed = expect(ne(err->find("members"), std::string::npos)); !failed) failed << *err; } auto resolved = mcpp::manifest::parse_string( "[package]\nname = \"m\"\nversion = \"0.1.0\"\n\n[dependencies]\nfmt = \"11.0.0\"\n"); - ASSERT_TRUE(resolved.has_value()); - EXPECT_FALSE(mcpp::project::unresolved_workspace_dependency_error(*resolved, "/p/m")); -} + expect(fatal(resolved.has_value())); + expect(!mcpp::project::unresolved_workspace_dependency_error(*resolved, "/p/m")); +}}; // #710. A host tool's toolchain is the one its own build would use: its host // row, then `[toolchain]`, each after the root-position keys of the workspace // that lists it. A member tool that declares nothing takes the workspace's. -TEST(HostToolToolchain, AMemberToolReadsItsWorkspaceToolchain) { +UTest a_member_tool_reads_its_workspace_toolchain{"HostToolToolchain/AMemberToolReadsItsWorkspaceToolchain", [] { namespace fs = std::filesystem; const auto root = fs::temp_directory_path() / std::format("mcpp-710-{:x}", std::random_device{}()); @@ -235,27 +236,27 @@ TEST(HostToolToolchain, AMemberToolReadsItsWorkspaceToolchain) { write(root / "tool" / "mcpp.toml", "[package]\nname = \"tool\"\nversion = \"0.1.0\"\n"); auto tool = mcpp::manifest::load(root / "tool" / "mcpp.toml"); - ASSERT_TRUE(tool.has_value()); + expect(fatal(tool.has_value())); // Compared through the value: with clang and the MSVC STL, constructing // std::optional from a literal fails to instantiate in this // translation unit, which includes gtest's headers and imports std. auto tc15 = mcpp::build::host_tool_declared_toolchain(*tool, root / "tool", "linux"); - ASSERT_TRUE(tc15.has_value()); - EXPECT_EQ(*tc15, "gcc@15.1.0"); + expect(fatal(tc15.has_value())); + expect(eq(*tc15, "gcc@15.1.0")); // The tool's own declaration wins over the workspace's. write(root / "tool" / "mcpp.toml", "[package]\nname = \"tool\"\nversion = \"0.1.0\"\n\n" "[toolchain]\ndefault = \"gcc@16.1.0\"\n"); tool = mcpp::manifest::load(root / "tool" / "mcpp.toml"); - ASSERT_TRUE(tool.has_value()); + expect(fatal(tool.has_value())); auto tc16 = mcpp::build::host_tool_declared_toolchain(*tool, root / "tool", "linux"); - ASSERT_TRUE(tc16.has_value()); - EXPECT_EQ(*tc16, "gcc@16.1.0"); + expect(fatal(tc16.has_value())); + expect(eq(*tc16, "gcc@16.1.0")); std::error_code ec; fs::remove_all(root, ec); -} +}}; // #725. `-p, --package ` resolves the package identity first, and the // directory spellings docs/07 §5.3 has always documented second @@ -297,7 +298,7 @@ struct Fixture { write("mcpp.toml", std::format( "[package]\nname = \"root\"\nversion = \"0.1.0\"\n\n{}", membersToml)); auto m = mcpp::manifest::load(root / "mcpp.toml"); - EXPECT_TRUE(m.has_value()) << (m ? "" : m.error().format()); + if (auto&& failed = expect(m.has_value()); !failed) failed << (m ? "" : m.error().format()); return m ? std::move(*m) : mcpp::manifest::Manifest{}; } }; @@ -305,7 +306,7 @@ struct Fixture { // A member's own package name selects it, its full path does, and so does // its directory's last segment (docs/07 §5.3's historical spellings, kept as // a fallback). -TEST(ResolveMember, PackageNamePathAndBasenameAllSelectTheOneMember) { +UTest package_name_path_and_basename_all_select_the_one_member{"ResolveMember/PackageNamePathAndBasenameAllSelectTheOneMember", [] { Fixture f("single"); f.member("modules/base", "ws-base"); auto ws = f.root_manifest("[workspace]\nmembers = [\"modules/base\"]\n"); @@ -316,152 +317,152 @@ TEST(ResolveMember, PackageNamePathAndBasenameAllSelectTheOneMember) { filters.push_back("modules\\base"); #endif for (std::string_view filter : filters) { - SCOPED_TRACE(std::string(filter)); - testing::internal::CaptureStderr(); + /* trace: std::string(filter) */; + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); auto m = mcpp::project::resolve_member_dir(ws, f.root, filter); - auto warned = testing::internal::GetCapturedStderr(); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error()); - EXPECT_EQ(*m, f.root / "modules/base"); - EXPECT_TRUE(warned.empty()) << warned; + auto warned = stderrCapture.text(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error()); + expect(eq(*m, f.root / "modules/base")); + expect(warned.empty()) << warned; } -} +}}; // Two members declare the same package name under different namespaces: // the bare name is refused (naming both qualified names), and each // qualified name selects its own member without any ambiguity. -TEST(ResolveMember, SameNameUnderTwoNamespacesRefusesTheBareNameOnly) { +UTest same_name_under_two_namespaces_refuses_the_bare_name_only{"ResolveMember/SameNameUnderTwoNamespacesRefusesTheBareNameOnly", [] { Fixture f("dup-ns"); f.member("a", "ws-common", "ns1"); f.member("b", "ws-common", "ns2"); auto ws = f.root_manifest("[workspace]\nmembers = [\"a\", \"b\"]\n"); auto bare = mcpp::project::resolve_member_dir(ws, f.root, "ws-common"); - ASSERT_FALSE(bare.has_value()); - EXPECT_NE(bare.error().find("ns1.ws-common"), std::string::npos) << bare.error(); - EXPECT_NE(bare.error().find("ns2.ws-common"), std::string::npos) << bare.error(); + expect(fatal(!bare.has_value())); + if (auto&& failed = expect(ne(bare.error().find("ns1.ws-common"), std::string::npos)); !failed) failed << bare.error(); + if (auto&& failed = expect(ne(bare.error().find("ns2.ws-common"), std::string::npos)); !failed) failed << bare.error(); auto qa = mcpp::project::resolve_member_dir(ws, f.root, "ns1.ws-common"); - ASSERT_TRUE(qa.has_value()) << qa.error(); - EXPECT_EQ(*qa, f.root / "a"); + if (auto&& failed = expect(fatal(qa.has_value())); !failed) failed << qa.error(); + expect(eq(*qa, f.root / "a")); auto qb = mcpp::project::resolve_member_dir(ws, f.root, "ns2.ws-common"); - ASSERT_TRUE(qb.has_value()) << qb.error(); - EXPECT_EQ(*qb, f.root / "b"); -} + if (auto&& failed = expect(fatal(qb.has_value())); !failed) failed << qb.error(); + expect(eq(*qb, f.root / "b")); +}}; // A value that is one member's package name and a different member's // directory selects the package -- the option names a package -- and warns, // naming the other member and its path. -TEST(ResolveMember, PackageNameOutranksAnotherMembersDirectoryAndWarns) { +UTest package_name_outranks_another_members_directory_and_warns{"ResolveMember/PackageNameOutranksAnotherMembersDirectoryAndWarns", [] { Fixture f("name-vs-dir"); f.member("modules/base", "ws-base"); f.member("ws-base", "other"); auto ws = f.root_manifest( "[workspace]\nmembers = [\"modules/base\", \"ws-base\"]\n"); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); auto m = mcpp::project::resolve_member_dir(ws, f.root, "ws-base"); - auto warned = testing::internal::GetCapturedStderr(); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error()); - EXPECT_EQ(*m, f.root / "modules/base"); - EXPECT_NE(warned.find("modules/base"), std::string::npos) << warned; - EXPECT_NE(warned.find("ws-base"), std::string::npos) << warned; -} + auto warned = stderrCapture.text(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error()); + expect(eq(*m, f.root / "modules/base")); + expect(ne(warned.find("modules/base"), std::string::npos)) << warned; + expect(ne(warned.find("ws-base"), std::string::npos)) << warned; +}}; // Two members share a directory basename (no package-name collision): the // first in `[workspace] members` is still selected (a script written against // it keeps working), and a warning now names the other and its path. -TEST(ResolveMember, DuplicateBasenameKeepsFirstMatchAndWarns) { +UTest duplicate_basename_keeps_first_match_and_warns{"ResolveMember/DuplicateBasenameKeepsFirstMatchAndWarns", [] { Fixture f("dup-basename"); f.member("apps/core", "coreapp"); f.member("libs/core", "corelib"); auto ws = f.root_manifest("[workspace]\nmembers = [\"apps/core\", \"libs/core\"]\n"); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); auto m = mcpp::project::resolve_member_dir(ws, f.root, "core"); - auto warned = testing::internal::GetCapturedStderr(); - ASSERT_TRUE(m.has_value()) << (m ? "" : m.error()); - EXPECT_EQ(*m, f.root / "apps/core"); - EXPECT_NE(warned.find("libs/core"), std::string::npos) << warned; -} + auto warned = stderrCapture.text(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? "" : m.error()); + expect(eq(*m, f.root / "apps/core")); + expect(ne(warned.find("libs/core"), std::string::npos)) << warned; +}}; // A filter matching nothing lists every member with its package name and its // path. -TEST(ResolveMember, NotFoundListsEveryMemberByNameAndPath) { +UTest not_found_lists_every_member_by_name_and_path{"ResolveMember/NotFoundListsEveryMemberByNameAndPath", [] { Fixture f("not-found"); f.member("a", "widget", "acme"); f.member("b", "gadget"); auto ws = f.root_manifest("[workspace]\nmembers = [\"a\", \"b\"]\n"); auto m = mcpp::project::resolve_member_dir(ws, f.root, "no-such-member"); - ASSERT_FALSE(m.has_value()); - EXPECT_NE(m.error().find("acme.widget"), std::string::npos) << m.error(); - EXPECT_NE(m.error().find("'a'"), std::string::npos) << m.error(); - EXPECT_NE(m.error().find("gadget"), std::string::npos) << m.error(); - EXPECT_NE(m.error().find("'b'"), std::string::npos) << m.error(); -} + expect(fatal(!m.has_value())); + if (auto&& failed = expect(ne(m.error().find("acme.widget"), std::string::npos)); !failed) failed << m.error(); + if (auto&& failed = expect(ne(m.error().find("'a'"), std::string::npos)); !failed) failed << m.error(); + if (auto&& failed = expect(ne(m.error().find("gadget"), std::string::npos)); !failed) failed << m.error(); + if (auto&& failed = expect(ne(m.error().find("'b'"), std::string::npos)); !failed) failed << m.error(); +}}; // No filter on a rooted workspace acts on the root package; no filter on a // virtual one is refused. Unaffected by #725, kept here as the resolver's // baseline. -TEST(ResolveMember, NoFilterActsOnRootedRootAndRefusesVirtual) { +UTest no_filter_acts_on_rooted_root_and_refuses_virtual{"ResolveMember/NoFilterActsOnRootedRootAndRefusesVirtual", [] { Fixture f("no-filter"); f.member("a", "a"); auto rooted = f.root_manifest("[workspace]\nmembers = [\"a\"]\n"); auto onRoot = mcpp::project::resolve_member_dir(rooted, f.root, ""); - ASSERT_TRUE(onRoot.has_value()) << onRoot.error(); - EXPECT_TRUE(onRoot->empty()); + if (auto&& failed = expect(fatal(onRoot.has_value())); !failed) failed << onRoot.error(); + expect(onRoot->empty()); auto virt = mcpp::manifest::parse_string("[workspace]\nmembers = [\"a\"]\n"); - ASSERT_TRUE(virt.has_value()) << virt.error().format(); + if (auto&& failed = expect(fatal(virt.has_value())); !failed) failed << virt.error().format(); auto onVirtual = mcpp::project::resolve_member_dir(*virt, f.root, ""); - ASSERT_FALSE(onVirtual.has_value()); - EXPECT_NE(onVirtual.error().find("--workspace"), std::string::npos); -} + expect(fatal(!onVirtual.has_value())); + expect(ne(onVirtual.error().find("--workspace"), std::string::npos)); +}}; } // namespace resolve_member // A `workspace = true` entry under a target selector, or behind a feature, // receives the workspace's source as one in [dependencies] does: a git // dependency inherited there resolved from the index before 2026.10.26.2. -TEST(WorkspaceInheritance, ConditionalAndFeatureTablesInheritTheSource) { +UTest conditional_and_feature_tables_inherit_the_source{"WorkspaceInheritance/ConditionalAndFeatureTablesInheritTheSource", [] { auto ws = mcpp::manifest::parse_string( "[workspace]\nmembers = [\"m\"]\n\n" "[workspace.dependencies]\n" "lib = { git = \"https://example.invalid/lib.git\", branch = \"main\" }\n" "tool = { path = \"tools/tool\" }\n"); - ASSERT_TRUE(ws.has_value()) << ws.error().format(); + if (auto&& failed = expect(fatal(ws.has_value())); !failed) failed << ws.error().format(); auto m = mcpp::manifest::parse_string( "[package]\nname = \"m\"\nversion = \"0.1.0\"\n\n" "[features]\nextra = []\n\n" "[feature-deps.extra]\ntool.workspace = true\n\n" "[target.'cfg(os = \"linux\")'.dependencies]\nlib.workspace = true\n"); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); mcpp::project::merge_workspace_deps(*m, *ws, "/ws"); - ASSERT_FALSE(m->conditionalConfigs.empty()); + expect(fatal(!(m->conditionalConfigs.empty()))); bool found = false; for (auto const& c : m->conditionalConfigs) { auto it = c.dependencies.find("lib"); if (it == c.dependencies.end()) continue; found = true; - EXPECT_FALSE(it->second.inheritWorkspace); - EXPECT_EQ(it->second.git, "https://example.invalid/lib.git"); - EXPECT_EQ(it->second.gitRev, "main"); + expect(!(it->second.inheritWorkspace)); + expect(eq(it->second.git, "https://example.invalid/lib.git")); + expect(eq(it->second.gitRev, "main")); } - EXPECT_TRUE(found); - ASSERT_TRUE(m->featureDeps.contains("extra")); + expect(bool(found)); + expect(fatal(m->featureDeps.contains("extra"))); auto const& tool = m->featureDeps.at("extra").at("tool"); - EXPECT_FALSE(tool.inheritWorkspace); - EXPECT_FALSE(tool.path.empty()); - EXPECT_FALSE(mcpp::project::unresolved_workspace_dependency_error(*m, "/ws/m").has_value()); -} + expect(!tool.inheritWorkspace); + expect(!tool.path.empty()); + expect(!(mcpp::project::unresolved_workspace_dependency_error(*m, "/ws/m").has_value())); +}}; -TEST(WorkspaceInheritance, AnUnresolvedConditionalEntryIsNamed) { +UTest an_unresolved_conditional_entry_is_named{"WorkspaceInheritance/AnUnresolvedConditionalEntryIsNamed", [] { auto m = mcpp::manifest::parse_string( "[package]\nname = \"m\"\nversion = \"0.1.0\"\n\n" "[target.'cfg(os = \"linux\")'.dependencies]\nlib.workspace = true\n"); - ASSERT_TRUE(m.has_value()) << m.error().format(); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); auto err = mcpp::project::unresolved_workspace_dependency_error(*m, "/m"); - ASSERT_TRUE(err.has_value()); - EXPECT_NE(err->find("dependencies] lib"), std::string::npos) << *err; -} + expect(fatal(err.has_value())); + if (auto&& failed = expect(ne(err->find("dependencies] lib"), std::string::npos)); !failed) failed << *err; +}}; diff --git a/tests/unit/test_workspace_plan.cpp b/tests/unit/test_workspace_plan.cpp index 297e93698..fbab2db02 100644 --- a/tests/unit/test_workspace_plan.cpp +++ b/tests/unit/test_workspace_plan.cpp @@ -1,9 +1,11 @@ -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.project; +using namespace mcpplibs::mtest; + // The unit-level statements of a workspace plan (workspace design 2026-09-29 // §15): which member values separate two plans, what the virtual root holds, // and where a member's products are placed. The end-to-end halves are @@ -28,7 +30,7 @@ mcpp::project::WorkspaceMember listed(std::string path, std::string ns, std::str // A member's own flags, sources and dependencies are attributes of its node: // they never separate it from another member. -TEST(WorkspacePlan, PackageAttributesDoNotSeparateMembers) { +UTest package_attributes_do_not_separate_members{"WorkspacePlan/PackageAttributesDoNotSeparateMembers", [] { auto a = member("a"); auto b = member("b"); b.buildConfig.cxxflags = {"-DONLY_B=1"}; @@ -36,33 +38,33 @@ TEST(WorkspacePlan, PackageAttributesDoNotSeparateMembers) { b.buildConfig.sources = {"src/**/*.cpp"}; b.buildConfig.cStandard = "c17"; b.dependencies["fmt"] = mcpp::manifest::DependencySpec{.version = "11.0.0"}; - EXPECT_EQ(mcpp::project::root_position_key(a), mcpp::project::root_position_key(b)); -} + expect(eq(mcpp::project::root_position_key(a), mcpp::project::root_position_key(b))); +}}; // What every node of one graph shares does separate them: the toolchain, the // standard, the dialect flags, the target and the profile. -TEST(WorkspacePlan, RootPositionValuesSeparateMembers) { +UTest root_position_values_separate_members{"WorkspacePlan/RootPositionValuesSeparateMembers", [] { const auto ref = mcpp::project::root_position_key(member("a")); { auto m = member("a"); m.package.standard = "c++26"; - EXPECT_NE(mcpp::project::root_position_key(m), ref); } + expect(ne(mcpp::project::root_position_key(m), ref)); } { auto m = member("a"); m.toolchain.byPlatform["default"] = "gcc@16.1.0"; - EXPECT_NE(mcpp::project::root_position_key(m), ref); } + expect(ne(mcpp::project::root_position_key(m), ref)); } { auto m = member("a"); m.buildConfig.dialectCxxflags = {"-freflection"}; - EXPECT_NE(mcpp::project::root_position_key(m), ref); } + expect(ne(mcpp::project::root_position_key(m), ref)); } { auto m = member("a"); m.buildConfig.target = "x86_64-linux-musl"; - EXPECT_NE(mcpp::project::root_position_key(m), ref); } + expect(ne(mcpp::project::root_position_key(m), ref)); } { auto m = member("a"); m.buildConfig.defaultProfile = "release"; - EXPECT_NE(mcpp::project::root_position_key(m), ref); } + expect(ne(mcpp::project::root_position_key(m), ref)); } { auto m = member("a"); m.buildConfig.cxxRuntime = "static"; - EXPECT_NE(mcpp::project::root_position_key(m), ref); } + expect(ne(mcpp::project::root_position_key(m), ref)); } // `linkage` chooses the C runtime every object of the plan is compiled // against, so it is the plan's and not the member's. { auto m = member("a"); m.buildConfig.linkage = "static"; - EXPECT_NE(mcpp::project::root_position_key(m), ref); } -} + expect(ne(mcpp::project::root_position_key(m), ref)); } +}}; // The virtual root carries the plan's values and nothing a package owns. -TEST(WorkspacePlan, TheVirtualRootHoldsNoPackageContent) { +UTest the_virtual_root_holds_no_package_content{"WorkspacePlan/TheVirtualRootHoldsNoPackageContent", [] { mcpp::manifest::Manifest ws; ws.workspace.present = true; ws.workspace.members = {"a"}; @@ -77,40 +79,40 @@ TEST(WorkspacePlan, TheVirtualRootHoldsNoPackageContent) { first.dependencies["fmt"] = mcpp::manifest::DependencySpec{.version = "11.0.0"}; const auto v = mcpp::project::virtual_workspace_root(ws, first, "/ws"); - EXPECT_TRUE(v.package.virtualRoot); - EXPECT_EQ(v.package.standard, "c++26"); - EXPECT_EQ(v.toolchain.byPlatform.at("default"), "gcc@16.1.0"); - EXPECT_EQ(v.buildConfig.dialectCxxflags, first.buildConfig.dialectCxxflags); - EXPECT_TRUE(v.buildConfig.cxxflags.empty()); - EXPECT_TRUE(v.buildConfig.ldflags.empty()); - EXPECT_TRUE(v.buildConfig.sources.empty()); - EXPECT_TRUE(v.buildConfig.sourcesDeclared); - EXPECT_TRUE(v.targets.empty()); - EXPECT_TRUE(v.dependencies.empty()); + expect(bool(v.package.virtualRoot)); + expect(eq(v.package.standard, "c++26")); + expect(eq(v.toolchain.byPlatform.at("default"), "gcc@16.1.0")); + expect(eq(v.buildConfig.dialectCxxflags, first.buildConfig.dialectCxxflags)); + expect(v.buildConfig.cxxflags.empty()); + expect(v.buildConfig.ldflags.empty()); + expect(v.buildConfig.sources.empty()); + expect(bool(v.buildConfig.sourcesDeclared)); + expect(v.targets.empty()); + expect(v.dependencies.empty()); // The root's values and the group's are one statement. - EXPECT_EQ(mcpp::project::root_position_key(v), mcpp::project::root_position_key(first)); -} + expect(eq(mcpp::project::root_position_key(v), mcpp::project::root_position_key(first))); +}}; // A member's products are in `bin//`, qualified when another // member of the workspace has the same name; the workspace's own package // keeps `bin/`. -TEST(WorkspacePlan, ProductDirectoriesAreNamedByPackage) { +UTest product_directories_are_named_by_package{"WorkspacePlan/ProductDirectoriesAreNamedByPackage", [] { const std::vector all = { listed("apps/cli", "", "cli"), listed("ns1/common", "ns1", "common"), listed("ns2/common", "ns2", "common"), }; - EXPECT_EQ(mcpp::project::product_directory_name(all, "apps/cli"), "cli"); - EXPECT_EQ(mcpp::project::product_directory_name(all, "ns1/common"), "ns1.common"); - EXPECT_EQ(mcpp::project::product_directory_name(all, "ns2/common"), "ns2.common"); - EXPECT_EQ(mcpp::project::product_directory_name(all, "."), ""); -} + expect(eq(mcpp::project::product_directory_name(all, "apps/cli"), "cli")); + expect(eq(mcpp::project::product_directory_name(all, "ns1/common"), "ns1.common")); + expect(eq(mcpp::project::product_directory_name(all, "ns2/common"), "ns2.common")); + expect(eq(mcpp::project::product_directory_name(all, "."), "")); +}}; // A relative `[indices].path` a member declares was written in the member's // directory, and one the workspace declares in the workspace's: both are // anchored where they were written, so two members naming one tree share a // configuration and a member's path never resolves against the plan's root. -TEST(WorkspacePlan, IndexPathsAreAnchoredWhereTheyWereWritten) { +UTest index_paths_are_anchored_where_they_were_written{"WorkspacePlan/IndexPathsAreAnchoredWhereTheyWereWritten", [] { namespace fs = std::filesystem; const auto ws = fs::temp_directory_path() / std::format("mcpp-ws-index-{}", std::random_device{}()); @@ -125,23 +127,23 @@ TEST(WorkspacePlan, IndexPathsAreAnchoredWhereTheyWereWritten) { write(ws / "b" / "mcpp.toml", "[indices]\nx = { path = \"../idx\" }\n\n[package]\nname = \"b\"\nversion = \"0.1.0\"\n"); auto root = mcpp::manifest::load(ws / "mcpp.toml"); - ASSERT_TRUE(root.has_value()); + expect(fatal(root.has_value())); auto a = mcpp::project::load_member_manifest(*root, ws, "a"); auto b = mcpp::project::load_member_manifest(*root, ws, "b"); - ASSERT_TRUE(a.has_value()) << a.error(); - ASSERT_TRUE(b.has_value()) << b.error(); - EXPECT_EQ(a->indices.at("x").path, (ws / "idx").lexically_normal()); - EXPECT_EQ(b->indices.at("x").path, (ws / "idx").lexically_normal()); - EXPECT_EQ(mcpp::project::root_position_key(*a), mcpp::project::root_position_key(*b)); + if (auto&& failed = expect(fatal(a.has_value())); !failed) failed << a.error(); + if (auto&& failed = expect(fatal(b.has_value())); !failed) failed << b.error(); + expect(eq(a->indices.at("x").path, (ws / "idx").lexically_normal())); + expect(eq(b->indices.at("x").path, (ws / "idx").lexically_normal())); + expect(eq(mcpp::project::root_position_key(*a), mcpp::project::root_position_key(*b))); std::error_code ec; fs::remove_all(ws, ec); -} +}}; // THE WORKSPACE'S PROFILES ARE ROOT-POSITION VALUES (2026.10.5.2). A member // inherits each `[profile.]` it does not declare, and its own table of a // name replaces the workspace's whole, so members without a profile of their // own share one configuration with the workspace's profile in it. -TEST(WorkspacePlan, MembersInheritTheWorkspacesProfilesByName) { +UTest members_inherit_the_workspaces_profiles_by_name{"WorkspacePlan/MembersInheritTheWorkspacesProfilesByName", [] { namespace fs = std::filesystem; const auto ws = fs::temp_directory_path() / std::format("mcpp-ws-profile-{}", std::random_device{}()); @@ -156,23 +158,23 @@ TEST(WorkspacePlan, MembersInheritTheWorkspacesProfilesByName) { write(ws / "b" / "mcpp.toml", "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n[profile.release]\nopt = 1\n"); auto root = mcpp::manifest::load(ws / "mcpp.toml"); - ASSERT_TRUE(root.has_value()); + expect(fatal(root.has_value())); auto a = mcpp::project::load_member_manifest(*root, ws, "a"); auto b = mcpp::project::load_member_manifest(*root, ws, "b"); - ASSERT_TRUE(a.has_value()) << a.error(); - ASSERT_TRUE(b.has_value()) << b.error(); - EXPECT_EQ(a->profiles.at("release").optLevel, "3"); - EXPECT_EQ(a->profiles.at("release").ldflags, (std::vector{"-Wl,-z,now"})); - EXPECT_EQ(b->profiles.at("release").optLevel, "1"); - EXPECT_TRUE(b->profiles.at("release").ldflags.empty()); - EXPECT_EQ(b->profiles.at("bench").optLevel, "2"); + if (auto&& failed = expect(fatal(a.has_value())); !failed) failed << a.error(); + if (auto&& failed = expect(fatal(b.has_value())); !failed) failed << b.error(); + expect(eq(a->profiles.at("release").optLevel, "3")); + expect(eq(a->profiles.at("release").ldflags, (std::vector{"-Wl,-z,now"}))); + expect(eq(b->profiles.at("release").optLevel, "1")); + expect(b->profiles.at("release").ldflags.empty()); + expect(eq(b->profiles.at("bench").optLevel, "2")); std::error_code ec; fs::remove_all(ws, ec); -} +}}; // THE ROOT PACKAGE OF A WORKSPACE IS A MEMBER (2026.10.5.2): it receives // `[workspace.build]` once, before its own `[build]`, on every path that reads it. -TEST(WorkspacePlan, TheRootPackageInheritsTheWorkspaceBuildOnce) { +UTest the_root_package_inherits_the_workspace_build_once{"WorkspacePlan/TheRootPackageInheritsTheWorkspaceBuildOnce", [] { namespace fs = std::filesystem; const auto ws = fs::temp_directory_path() / std::format("mcpp-ws-rooted-{}", std::random_device{}()); @@ -185,18 +187,18 @@ TEST(WorkspacePlan, TheRootPackageInheritsTheWorkspaceBuildOnce) { std::ofstream(ws / "lib" / "mcpp.toml") << "[package]\nname = \"lib\"\nversion = \"0.1.0\"\n"; const std::vector expected{"-DSHARED", "-DOWN"}; auto effective = mcpp::project::load_effective_manifest(ws); - ASSERT_TRUE(effective.has_value()) << effective.error(); - EXPECT_EQ(effective->manifest.buildConfig.cxxflags, expected); + if (auto&& failed = expect(fatal(effective.has_value())); !failed) failed << effective.error(); + expect(eq(effective->manifest.buildConfig.cxxflags, expected)); // The manifest a selection reads is the one already inherited; the member // "." loaded from it is not inherited a second time. auto dot = mcpp::project::load_member_manifest(effective->manifest, ws, "."); - ASSERT_TRUE(dot.has_value()) << dot.error(); - EXPECT_EQ(dot->buildConfig.cxxflags, expected); + if (auto&& failed = expect(fatal(dot.has_value())); !failed) failed << dot.error(); + expect(eq(dot->buildConfig.cxxflags, expected)); auto raw = mcpp::manifest::load(ws / "mcpp.toml"); - ASSERT_TRUE(raw.has_value()); + expect(fatal(raw.has_value())); auto fromRaw = mcpp::project::load_member_manifest(*raw, ws, "."); - ASSERT_TRUE(fromRaw.has_value()) << fromRaw.error(); - EXPECT_EQ(fromRaw->buildConfig.cxxflags, expected); + if (auto&& failed = expect(fatal(fromRaw.has_value())); !failed) failed << fromRaw.error(); + expect(eq(fromRaw->buildConfig.cxxflags, expected)); std::error_code ec; fs::remove_all(ws, ec); -} +}}; diff --git a/tests/unit/test_xlings.cpp b/tests/unit/test_xlings.cpp index 0101c25ca..1f7a7cacc 100644 --- a/tests/unit/test_xlings.cpp +++ b/tests/unit/test_xlings.cpp @@ -1,10 +1,12 @@ -#include import std; +import mcpplibs.mtest; import mcpplibs.os.platform; import mcpp.provider.xlings; import mcpp.invocation; +using namespace mcpplibs::mtest; + namespace { std::filesystem::path make_tempdir(std::string_view name) { @@ -86,7 +88,7 @@ std::string path_without_real_nasm() { } // namespace -TEST(FindUsableNasm, FindsOnPathWhenVersionIsAdequate) { +UTest finds_on_path_when_version_is_adequate{"FindUsableNasm/FindsOnPathWhenVersionIsAdequate", [] { auto pathDir = make_tempdir("mcpp-nasm-path"); auto expected = make_fake_nasm(pathDir, "NASM version 2.16.03 compiled on Jan 1 2026"); @@ -100,14 +102,14 @@ TEST(FindUsableNasm, FindsOnPathWhenVersionIsAdequate) { mcpp::provider::xlings::Env env{.home = home}; auto found = mcpp::provider::xlings::find_usable_nasm(env); - ASSERT_TRUE(found.has_value()); - EXPECT_EQ(*found, expected); + expect(fatal(found.has_value())); + expect(eq(*found, expected)); std::filesystem::remove_all(pathDir); std::filesystem::remove_all(home); -} +}}; -TEST(FindUsableNasm, RejectsPathNasmBelowMinVersionAndFindsNoSandboxFallback) { +UTest rejects_path_nasm_below_min_version_and_finds_no_sandbox_fallback{"FindUsableNasm/RejectsPathNasmBelowMinVersionAndFindsNoSandboxFallback", [] { auto pathDir = make_tempdir("mcpp-nasm-path-old"); make_fake_nasm(pathDir, "NASM version 2.10.00 compiled on Jan 1 2020"); @@ -117,13 +119,13 @@ TEST(FindUsableNasm, RejectsPathNasmBelowMinVersionAndFindsNoSandboxFallback) { auto home = make_tempdir("mcpp-nasm-home-empty"); // no sandbox nasm mcpp::provider::xlings::Env env{.home = home}; - EXPECT_FALSE(mcpp::provider::xlings::find_usable_nasm(env).has_value()); + expect(!(mcpp::provider::xlings::find_usable_nasm(env).has_value())); std::filesystem::remove_all(pathDir); std::filesystem::remove_all(home); -} +}}; -TEST(FindUsableNasm, FallsBackToSandboxWhenPathHasNoNasm) { +UTest falls_back_to_sandbox_when_path_has_no_nasm{"FindUsableNasm/FallsBackToSandboxWhenPathHasNoNasm", [] { // PATH has the usual system directories (so the sandbox script's own // shebang still resolves) but none of them contain a real `nasm`. mcpp::invocation::ScopedEnv path("PATH", path_without_real_nasm()); @@ -135,13 +137,13 @@ TEST(FindUsableNasm, FallsBackToSandboxWhenPathHasNoNasm) { mcpp::provider::xlings::Env env{.home = home}; auto found = mcpp::provider::xlings::find_usable_nasm(env); - ASSERT_TRUE(found.has_value()); - EXPECT_EQ(*found, expected); + expect(fatal(found.has_value())); + expect(eq(*found, expected)); std::filesystem::remove_all(home); -} +}}; -TEST(FindSandboxNasm, FindsUnderVersionedBinSubdir) { +UTest finds_under_versioned_bin_subdir{"FindSandboxNasm/FindsUnderVersionedBinSubdir", [] { auto home = make_tempdir("mcpp-nasm-home-bin-subdir"); auto sandboxDir = home / "data" / "xpkgs" / "xim-x-nasm" / "3.02" / "bin"; auto expected = make_fake_nasm(sandboxDir, "NASM version 3.02 compiled on Jan 1 2026"); @@ -149,70 +151,70 @@ TEST(FindSandboxNasm, FindsUnderVersionedBinSubdir) { mcpp::provider::xlings::Env env{.home = home}; auto found = mcpp::provider::xlings::find_sandbox_nasm(env); - ASSERT_TRUE(found.has_value()); - EXPECT_EQ(*found, expected); + expect(fatal(found.has_value())); + expect(eq(*found, expected)); std::filesystem::remove_all(home); -} +}}; -TEST(FindSandboxNasm, RejectsInstalledNasmBelowMinVersion) { +UTest rejects_installed_nasm_below_min_version{"FindSandboxNasm/RejectsInstalledNasmBelowMinVersion", [] { auto home = make_tempdir("mcpp-nasm-home-old-sandboxed"); auto sandboxDir = home / "data" / "xpkgs" / "xim-x-nasm" / "2.10"; make_fake_nasm(sandboxDir, "NASM version 2.10.00 compiled on Jan 1 2020"); mcpp::provider::xlings::Env env{.home = home}; - EXPECT_FALSE(mcpp::provider::xlings::find_sandbox_nasm(env).has_value()); + expect(!(mcpp::provider::xlings::find_sandbox_nasm(env).has_value())); std::filesystem::remove_all(home); -} +}}; -TEST(FindSandboxNasm, NoSandboxDirectoryYieldsNullopt) { +UTest no_sandbox_directory_yields_nullopt{"FindSandboxNasm/NoSandboxDirectoryYieldsNullopt", [] { auto home = make_tempdir("mcpp-nasm-home-absent"); mcpp::provider::xlings::Env env{.home = home}; - EXPECT_FALSE(mcpp::provider::xlings::find_sandbox_nasm(env).has_value()); + expect(!(mcpp::provider::xlings::find_sandbox_nasm(env).has_value())); std::filesystem::remove_all(home); -} +}}; #endif // !defined(_WIN32) -TEST(XlingsIndexFreshness, RequiresDefaultMcpplibsIndex) { +UTest requires_default_mcpplibs_index{"XlingsIndexFreshness/RequiresDefaultMcpplibsIndex", [] { auto home = make_tempdir("mcpp-xlings-index-freshness"); std::filesystem::create_directories(home / "data" / "xim-pkgindex" / "pkgs"); mcpp::provider::xlings::Env env{.home = home}; - EXPECT_FALSE(mcpp::provider::xlings::is_index_fresh(env, 3600)); + expect(!mcpp::provider::xlings::is_index_fresh(env, 3600)); std::filesystem::remove_all(home); -} +}}; -TEST(XlingsIndexFreshness, AcceptsFreshDefaultMcpplibsIndex) { +UTest accepts_fresh_default_mcpplibs_index{"XlingsIndexFreshness/AcceptsFreshDefaultMcpplibsIndex", [] { auto home = make_tempdir("mcpp-xlings-index-freshness"); std::filesystem::create_directories(home / "data" / "mcpplibs" / "pkgs"); std::ofstream(home / "data" / "mcpplibs" / ".mcpp-index-updated") << "ok\n"; mcpp::provider::xlings::Env env{.home = home}; - EXPECT_TRUE(mcpp::provider::xlings::is_index_fresh(env, 3600)); + expect(mcpp::provider::xlings::is_index_fresh(env, 3600)); std::filesystem::remove_all(home); -} +}}; -TEST(XlingsIndexFreshness, RequiresRefreshMarkerForDefaultMcpplibsIndex) { +UTest requires_refresh_marker_for_default_mcpplibs_index{"XlingsIndexFreshness/RequiresRefreshMarkerForDefaultMcpplibsIndex", [] { auto home = make_tempdir("mcpp-xlings-index-freshness"); std::filesystem::create_directories(home / "data" / "mcpplibs" / "pkgs"); mcpp::provider::xlings::Env env{.home = home}; - EXPECT_FALSE(mcpp::provider::xlings::is_index_fresh(env, 3600)); + expect(!mcpp::provider::xlings::is_index_fresh(env, 3600)); std::filesystem::remove_all(home); -} +}}; -TEST(XlingsIndexFreshness, RejectsStaleRefreshMarker) { +UTest rejects_stale_refresh_marker{"XlingsIndexFreshness/RejectsStaleRefreshMarker", [] { auto home = make_tempdir("mcpp-xlings-index-freshness"); std::filesystem::create_directories(home / "data" / "mcpplibs" / "pkgs"); auto marker = home / "data" / "mcpplibs" / ".mcpp-index-updated"; @@ -222,48 +224,48 @@ TEST(XlingsIndexFreshness, RejectsStaleRefreshMarker) { mcpp::provider::xlings::Env env{.home = home}; - EXPECT_FALSE(mcpp::provider::xlings::is_index_fresh(env, 3600)); + expect(!mcpp::provider::xlings::is_index_fresh(env, 3600)); std::filesystem::remove_all(home); -} +}}; -TEST(XlingsIndexFreshness, RequiresOfficialXimIndexEvenWhenDefaultIndexIsFresh) { +UTest requires_official_xim_index_even_when_default_index_is_fresh{"XlingsIndexFreshness/RequiresOfficialXimIndexEvenWhenDefaultIndexIsFresh", [] { auto home = make_tempdir("mcpp-xlings-index-freshness"); std::filesystem::create_directories(home / "data" / "mcpplibs" / "pkgs"); std::ofstream(home / "data" / "mcpplibs" / ".mcpp-index-updated") << "ok\n"; mcpp::provider::xlings::Env env{.home = home}; - EXPECT_FALSE(mcpp::provider::xlings::is_official_index_fresh(env, 3600)); + expect(!mcpp::provider::xlings::is_official_index_fresh(env, 3600)); std::filesystem::remove_all(home); -} +}}; -TEST(XlingsIndexFreshness, AcceptsFreshOfficialXimIndex) { +UTest accepts_fresh_official_xim_index{"XlingsIndexFreshness/AcceptsFreshOfficialXimIndex", [] { auto home = make_tempdir("mcpp-xlings-index-freshness"); std::filesystem::create_directories(home / "data" / "xim-pkgindex" / "pkgs"); std::ofstream(home / "data" / "xim-pkgindex" / ".mcpp-index-updated") << "ok\n"; mcpp::provider::xlings::Env env{.home = home}; - EXPECT_TRUE(mcpp::provider::xlings::is_official_index_fresh(env, 3600)); + expect(mcpp::provider::xlings::is_official_index_fresh(env, 3600)); std::filesystem::remove_all(home); -} +}}; -TEST(XlingsIndexFreshness, RequiresOfficialPackageFileEvenWhenOfficialIndexIsFresh) { +UTest requires_official_package_file_even_when_official_index_is_fresh{"XlingsIndexFreshness/RequiresOfficialPackageFileEvenWhenOfficialIndexIsFresh", [] { auto home = make_tempdir("mcpp-xlings-index-freshness"); std::filesystem::create_directories(home / "data" / "xim-pkgindex" / "pkgs"); std::ofstream(home / "data" / "xim-pkgindex" / ".mcpp-index-updated") << "ok\n"; mcpp::provider::xlings::Env env{.home = home}; - EXPECT_FALSE(mcpp::provider::xlings::is_official_package_index_fresh(env, "musl-gcc", 3600)); + expect(!mcpp::provider::xlings::is_official_package_index_fresh(env, "musl-gcc", 3600)); std::filesystem::remove_all(home); -} +}}; -TEST(XlingsIndexFreshness, AcceptsFreshOfficialPackageFile) { +UTest accepts_fresh_official_package_file{"XlingsIndexFreshness/AcceptsFreshOfficialPackageFile", [] { auto home = make_tempdir("mcpp-xlings-index-freshness"); std::filesystem::create_directories(home / "data" / "xim-pkgindex" / "pkgs" / "m"); std::ofstream(home / "data" / "xim-pkgindex" / ".mcpp-index-updated") << "ok\n"; @@ -271,12 +273,12 @@ TEST(XlingsIndexFreshness, AcceptsFreshOfficialPackageFile) { mcpp::provider::xlings::Env env{.home = home}; - EXPECT_TRUE(mcpp::provider::xlings::is_official_package_index_fresh(env, "musl-gcc", 3600)); + expect(mcpp::provider::xlings::is_official_package_index_fresh(env, "musl-gcc", 3600)); std::filesystem::remove_all(home); -} +}}; -TEST(XlingsIndexFreshness, RejectsOfficialPackageCacheWithForeignPath) { +UTest rejects_official_package_cache_with_foreign_path{"XlingsIndexFreshness/RejectsOfficialPackageCacheWithForeignPath", [] { auto home = make_tempdir("mcpp-xlings-index-freshness"); auto pkg = home / "data" / "xim-pkgindex" / "pkgs" / "m" / "musl-gcc.lua"; std::filesystem::create_directories(pkg.parent_path()); @@ -287,12 +289,12 @@ TEST(XlingsIndexFreshness, RejectsOfficialPackageCacheWithForeignPath) { mcpp::provider::xlings::Env env{.home = home}; - EXPECT_FALSE(mcpp::provider::xlings::is_official_package_index_fresh(env, "musl-gcc", 3600)); + expect(!mcpp::provider::xlings::is_official_package_index_fresh(env, "musl-gcc", 3600)); std::filesystem::remove_all(home); -} +}}; -TEST(XlingsIndexFreshness, AcceptsOfficialPackageCacheWithCurrentPath) { +UTest accepts_official_package_cache_with_current_path{"XlingsIndexFreshness/AcceptsOfficialPackageCacheWithCurrentPath", [] { auto home = make_tempdir("mcpp-xlings-index-freshness"); auto pkg = home / "data" / "xim-pkgindex" / "pkgs" / "m" / "musl-gcc.lua"; std::filesystem::create_directories(pkg.parent_path()); @@ -303,10 +305,10 @@ TEST(XlingsIndexFreshness, AcceptsOfficialPackageCacheWithCurrentPath) { mcpp::provider::xlings::Env env{.home = home}; - EXPECT_TRUE(mcpp::provider::xlings::is_official_package_index_fresh(env, "musl-gcc", 3600)); + expect(mcpp::provider::xlings::is_official_package_index_fresh(env, "musl-gcc", 3600)); std::filesystem::remove_all(home); -} +}}; // ─── Sibling/home payload discovery (issue #120) ───────────────────── // @@ -324,7 +326,7 @@ void touch(const std::filesystem::path& p, std::string_view content = "x") { } // namespace -TEST(XlingsSiblingPackage, MetadataOnlyHuskIsNotContent) { +UTest metadata_only_husk_is_not_content{"XlingsSiblingPackage/MetadataOnlyHuskIsNotContent", [] { auto tmp = make_tempdir("mcpp-husk"); auto xpkgs = tmp / "xpkgs"; auto gccBin = xpkgs / "xim-x-gcc" / "16.1.0" / "bin" / "g++"; @@ -342,12 +344,12 @@ TEST(XlingsSiblingPackage, MetadataOnlyHuskIsNotContent) { found = mcpp::provider::xlings::paths::find_sibling_package(gccBin, "linux-headers"); } - EXPECT_FALSE(found.has_value()); + expect(!found.has_value()); std::filesystem::remove_all(tmp); -} +}}; -TEST(XlingsSiblingPackage, SkipsHuskAndFindsPayloadUnderOtherPrefix) { +UTest skips_husk_and_finds_payload_under_other_prefix{"XlingsSiblingPackage/SkipsHuskAndFindsPayloadUnderOtherPrefix", [] { auto tmp = make_tempdir("mcpp-husk"); auto xpkgs = tmp / "xpkgs"; auto gccBin = xpkgs / "xim-x-gcc" / "16.1.0" / "bin" / "g++"; @@ -361,13 +363,13 @@ TEST(XlingsSiblingPackage, SkipsHuskAndFindsPayloadUnderOtherPrefix) { touch(real / "include" / "linux" / "limits.h"); auto found = mcpp::provider::xlings::paths::find_sibling_package(gccBin, "linux-headers"); - ASSERT_TRUE(found.has_value()); - EXPECT_EQ(*found, real); + expect(fatal(found.has_value())); + expect(eq(*found, real)); std::filesystem::remove_all(tmp); -} +}}; -TEST(XlingsSiblingPackage, RequiredRelPathRejectsContentfulButWrongCandidate) { +UTest required_rel_path_rejects_contentful_but_wrong_candidate{"XlingsSiblingPackage/RequiredRelPathRejectsContentfulButWrongCandidate", [] { auto tmp = make_tempdir("mcpp-husk"); auto xpkgs = tmp / "xpkgs"; auto gccBin = xpkgs / "xim-x-gcc" / "16.1.0" / "bin" / "g++"; @@ -382,13 +384,13 @@ TEST(XlingsSiblingPackage, RequiredRelPathRejectsContentfulButWrongCandidate) { auto found = mcpp::provider::xlings::paths::find_sibling_package( gccBin, "linux-headers", "include/linux/limits.h"); - ASSERT_TRUE(found.has_value()); - EXPECT_EQ(*found, real); + expect(fatal(found.has_value())); + expect(eq(*found, real)); std::filesystem::remove_all(tmp); -} +}}; -TEST(XlingsHomeTool, FindsPayloadUnderNonXimPrefix) { +UTest finds_payload_under_non_xim_prefix{"XlingsHomeTool/FindsPayloadUnderNonXimPrefix", [] { auto tmp = make_tempdir("mcpp-husk-home"); auto xpkgs = tmp / "registry" / "data" / "xpkgs"; @@ -406,12 +408,12 @@ TEST(XlingsHomeTool, FindsPayloadUnderNonXimPrefix) { "linux-headers", "include/linux/limits.h"); } - ASSERT_TRUE(found.has_value()); + expect(fatal(found.has_value())); // The original home is normalized; macOS spells /var's target /private/var. - EXPECT_TRUE(std::filesystem::equivalent(*found, real)); + expect(bool(std::filesystem::equivalent(*found, real))); std::filesystem::remove_all(tmp); -} +}}; // ─── seed_xlings_json — per-repo artifact/source emission (#269) ────── @@ -425,7 +427,7 @@ std::string read_seeded(const std::filesystem::path& home) { } // namespace -TEST(XlingsSeed, PlainRepoEmissionIsByteStable) { +UTest plain_repo_emission_is_byte_stable{"XlingsSeed/PlainRepoEmissionIsByteStable", [] { // Zero-diff gate: repos without artifact/source must serialize exactly // as before the SeedRepo extension. auto dir = make_tempdir("mcpp-seed-plain"); @@ -435,15 +437,14 @@ TEST(XlingsSeed, PlainRepoEmissionIsByteStable) { { "mcpplibs", "https://x.git", "", "" } }; mcpp::provider::xlings::seed_xlings_json(env, repos); auto text = read_seeded(dir); - EXPECT_NE(text.find( - " { \"name\": \"mcpplibs\", \"url\": \"https://x.git\" }\n"), - std::string::npos) << text; - EXPECT_EQ(text.find("artifact"), std::string::npos); - EXPECT_EQ(text.find("source"), std::string::npos); + expect(ne(text.find( + " { \"name\": \"mcpplibs\", \"url\": \"https://x.git\" }\n"), std::string::npos)) << text; + expect(eq(text.find("artifact"), std::string::npos)); + expect(eq(text.find("source"), std::string::npos)); std::filesystem::remove_all(dir); -} +}}; -TEST(XlingsSeed, ArtifactAndSourceEmittedWhenSet) { +UTest artifact_and_source_emitted_when_set{"XlingsSeed/ArtifactAndSourceEmittedWhenSet", [] { auto dir = make_tempdir("mcpp-seed-art"); mcpp::provider::xlings::Env env; env.home = dir; @@ -452,16 +453,15 @@ TEST(XlingsSeed, ArtifactAndSourceEmittedWhenSet) { "https://github.com/xlings-res/mcpp-index", "auto" } }; mcpp::provider::xlings::seed_xlings_json(env, repos); auto text = read_seeded(dir); - EXPECT_NE(text.find( + expect(ne(text.find( "\"url\": \"https://x.git\", \"artifact\": " - "\"https://github.com/xlings-res/mcpp-index\", \"source\": \"auto\""), - std::string::npos) << text; + "\"https://github.com/xlings-res/mcpp-index\", \"source\": \"auto\""), std::string::npos)) << text; std::filesystem::remove_all(dir); -} +}}; // ─── Indices mcpp owns: their places (design 2026-10-16 §5) ────────────── -TEST(XlingsOwnedIndex, SeedNamesItsDataAndStore) { +UTest seed_names_its_data_and_store{"XlingsOwnedIndex/SeedNamesItsDataAndStore", [] { auto dir = make_tempdir("mcpp-seed-owned"); mcpp::provider::xlings::Env env; env.home = dir / "registry"; @@ -471,29 +471,29 @@ TEST(XlingsOwnedIndex, SeedNamesItsDataAndStore) { std::vector repos{ repo }; mcpp::provider::xlings::seed_xlings_json(env, repos); const auto declared = mcpp::provider::xlings::paths::declared_indices(env); - ASSERT_EQ(declared.size(), 1u); - EXPECT_TRUE(declared[0].owned); - EXPECT_EQ(declared[0].dir, (dir / "indices" / "mcpplibs").lexically_normal()); - EXPECT_EQ(declared[0].store, (dir / "xpkgs").lexically_normal()); + expect(fatal(eq(declared.size(), 1u))); + expect(bool(declared[0].owned)); + expect(eq(declared[0].dir, (dir / "indices" / "mcpplibs").lexically_normal())); + expect(eq(declared[0].store, (dir / "xpkgs").lexically_normal())); // Its store first, the home's after it (payloads from before, §5.3). const auto stores = mcpp::provider::xlings::paths::stores(env); - ASSERT_EQ(stores.size(), 2u); - EXPECT_EQ(stores[0], (dir / "xpkgs").lexically_normal()); - EXPECT_EQ(stores[1], env.home / "data" / "xpkgs"); + expect(fatal(eq(stores.size(), 2u))); + expect(eq(stores[0], (dir / "xpkgs").lexically_normal())); + expect(eq(stores[1], env.home / "data" / "xpkgs")); std::filesystem::remove_all(dir); -} +}}; -TEST(XlingsOwnedIndex, AHomeServesItsDefaultAndItsConfiguredIndexes) { +UTest a_home_serves_its_default_and_its_configured_indexes{"XlingsOwnedIndex/AHomeServesItsDefaultAndItsConfiguredIndexes", [] { auto dir = make_tempdir("mcpp-indices-of"); - EXPECT_TRUE(mcpp::provider::xlings::paths::indices_of(dir).empty()); // not a provider home + expect(mcpp::provider::xlings::paths::indices_of(dir).empty()); // not a provider home std::ofstream(dir / ".xlings.json") << "{}"; - EXPECT_EQ(mcpp::provider::xlings::paths::indices_of(dir), std::vector{"xim"}); + expect(eq(mcpp::provider::xlings::paths::indices_of(dir), std::vector{"xim"})); std::ofstream(dir / ".xlings.json") << R"({"index_repos":[{"name":"mcpp-index","url":"u"},{"name":"xim","url":"v"}]})"; - EXPECT_EQ(mcpp::provider::xlings::paths::indices_of(dir), (std::vector{"xim", "mcpp-index"})); + expect(eq(mcpp::provider::xlings::paths::indices_of(dir), (std::vector{"xim", "mcpp-index"}))); std::filesystem::remove_all(dir); -} +}}; -TEST(XlingsOwnedIndex, AHomeFromBeforeHasItsIndexMovedOnce) { +UTest a_home_from_before_has_its_index_moved_once{"XlingsOwnedIndex/AHomeFromBeforeHasItsIndexMovedOnce", [] { auto dir = make_tempdir("mcpp-own-indices"); mcpp::provider::xlings::Env env; env.home = dir / "registry"; @@ -509,28 +509,28 @@ TEST(XlingsOwnedIndex, AHomeFromBeforeHasItsIndexMovedOnce) { owned.store = dir / "xpkgs"; std::vector repos{ owned }; mcpp::provider::xlings::paths::own_indices(env, repos); - EXPECT_FALSE(std::filesystem::exists(old)); - EXPECT_TRUE(std::filesystem::exists(owned.dir / "pkgs" / "f" / "foo.lua")); + expect(!std::filesystem::exists(old)); + expect(std::filesystem::exists(owned.dir / "pkgs" / "f" / "foo.lua")); const auto declared = mcpp::provider::xlings::paths::declared_indices(env); - ASSERT_EQ(declared.size(), 1u); - EXPECT_TRUE(declared[0].owned); + expect(fatal(eq(declared.size(), 1u))); + expect(bool(declared[0].owned)); // Read from both places: the provider's data dir and the owned one. const auto roots = mcpp::provider::xlings::paths::index_roots(env); - EXPECT_NE(std::ranges::find(roots, owned.dir.lexically_normal()), roots.end()); - EXPECT_NE(std::ranges::find(roots, env.home / "data" / "xim-pkgindex"), roots.end()); + expect(ne(std::ranges::find(roots, owned.dir.lexically_normal()), roots.end())); + expect(ne(std::ranges::find(roots, env.home / "data" / "xim-pkgindex"), roots.end())); // Once: data that reappears at the old place is not moved over the new. std::filesystem::create_directories(old / "pkgs"); mcpp::provider::xlings::paths::own_indices(env, repos); - EXPECT_TRUE(std::filesystem::exists(old)); - EXPECT_TRUE(std::filesystem::exists(owned.dir / "pkgs" / "f" / "foo.lua")); + expect(std::filesystem::exists(old)); + expect(std::filesystem::exists(owned.dir / "pkgs" / "f" / "foo.lua")); // And it is not read as a second index. const auto again = mcpp::provider::xlings::paths::index_roots(env); - EXPECT_EQ(std::ranges::find(again, old), again.end()); + expect(eq(std::ranges::find(again, old), again.end())); std::filesystem::remove_all(dir); -} +}}; -TEST(XlingsOwnedIndex, APayloadIsFoundInItsIndexStoreThenInTheHomes) { +UTest a_payload_is_found_in_its_index_store_then_in_the_homes{"XlingsOwnedIndex/APayloadIsFoundInItsIndexStoreThenInTheHomes", [] { auto dir = make_tempdir("mcpp-owned-payload"); mcpp::provider::xlings::Env env; env.home = dir / "registry"; @@ -544,17 +544,17 @@ TEST(XlingsOwnedIndex, APayloadIsFoundInItsIndexStoreThenInTheHomes) { std::filesystem::create_directories(dir / "xpkgs" / "mcpplibs-x-cmdline" / "0.0.1" / "src"); using mcpp::provider::xlings::paths::xpkg_payload; auto zlib = xpkg_payload(env, { "compat", "zlib", "1.3.1" }); - ASSERT_TRUE(zlib.has_value()); - EXPECT_EQ(*zlib, env.home / "data" / "xpkgs" / "compat-x-zlib" / "1.3.1"); + expect(fatal(zlib.has_value())); + expect(eq(*zlib, env.home / "data" / "xpkgs" / "compat-x-zlib" / "1.3.1")); auto cmdline = xpkg_payload(env, { "mcpplibs", "cmdline", "" }); - ASSERT_TRUE(cmdline.has_value()); - EXPECT_EQ(*cmdline, (dir / "xpkgs").lexically_normal() / "mcpplibs-x-cmdline" / "0.0.1"); + expect(fatal(cmdline.has_value())); + expect(eq(*cmdline, (dir / "xpkgs").lexically_normal() / "mcpplibs-x-cmdline" / "0.0.1")); std::filesystem::remove_all(dir); -} +}}; // ─── Index identity + clock-skew hardening (#315) ──────────────────────── -TEST(XlingsIndexFreshness, FutureMarkerIsStaleNotEternallyFresh) { +UTest future_marker_is_stale_not_eternally_fresh{"XlingsIndexFreshness/FutureMarkerIsStaleNotEternallyFresh", [] { auto home = make_tempdir("mcpp-xlings-index-future"); auto dir = home / "data" / "mcpplibs"; std::filesystem::create_directories(dir / "pkgs"); @@ -570,40 +570,40 @@ TEST(XlingsIndexFreshness, FutureMarkerIsStaleNotEternallyFresh) { mcpp::provider::xlings::Env env{.home = home}; auto st = mcpp::provider::xlings::default_index_status(env, 3600); - EXPECT_FALSE(st.fresh); - EXPECT_EQ(st.ageSeconds, -1); // unusable timestamp reports as unknown + expect(!st.fresh); + expect(eq(st.ageSeconds, -1)); // unusable timestamp reports as unknown std::filesystem::remove_all(home); -} +}}; -TEST(XlingsIndexRevision, ReadsTrimmedValueAndDegradesToNullopt) { +UTest reads_trimmed_value_and_degrades_to_nullopt{"XlingsIndexRevision/ReadsTrimmedValueAndDegradesToNullopt", [] { auto home = make_tempdir("mcpp-xlings-index-rev"); auto dir = home / "data" / "mcpplibs"; std::filesystem::create_directories(dir / "pkgs"); // Absent: a local `path` index has no such file — must not be an error. - EXPECT_FALSE(mcpp::provider::xlings::index_revision(dir).has_value()); + expect(!(mcpp::provider::xlings::index_revision(dir).has_value())); // Present. The observed files carry no trailing newline, but a writer that // added one (or a \r from Windows) must not change the value, or every run // would look like the index had changed. std::ofstream(dir / ".xlings-index-version") << " 8d67478\r\n"; auto rev = mcpp::provider::xlings::index_revision(dir); - ASSERT_TRUE(rev.has_value()); - EXPECT_EQ(*rev, "8d67478"); + expect(fatal(rev.has_value())); + expect(eq(*rev, "8d67478")); // Blank is not an identity. std::ofstream(dir / ".xlings-index-version", std::ios::trunc) << "\n"; - EXPECT_FALSE(mcpp::provider::xlings::index_revision(dir).has_value()); + expect(!(mcpp::provider::xlings::index_revision(dir).has_value())); // The value is OPAQUE: sub-indexes carry a date version, not a sha. std::ofstream(dir / ".xlings-index-version", std::ios::trunc) << "2026.7.30.1"; - EXPECT_EQ(mcpp::provider::xlings::index_revision(dir).value(), "2026.7.30.1"); + expect(eq(mcpp::provider::xlings::index_revision(dir).value(), "2026.7.30.1")); std::filesystem::remove_all(home); -} +}}; -TEST(XlingsIndexRevision, StatusCarriesTheRevision) { +UTest status_carries_the_revision{"XlingsIndexRevision/StatusCarriesTheRevision", [] { auto home = make_tempdir("mcpp-xlings-index-rev-status"); auto dir = home / "data" / "mcpplibs"; std::filesystem::create_directories(dir / "pkgs"); @@ -613,11 +613,11 @@ TEST(XlingsIndexRevision, StatusCarriesTheRevision) { mcpp::provider::xlings::Env env{.home = home}; auto st = mcpp::provider::xlings::default_index_status(env, 3600); - ASSERT_TRUE(st.rev.has_value()); - EXPECT_EQ(*st.rev, "abc1234"); + expect(fatal(st.rev.has_value())); + expect(eq(*st.rev, "abc1234")); std::filesystem::remove_all(home); -} +}}; // ─── stderr_error_tail (#614) ───────────────────────────────────────── // @@ -625,32 +625,30 @@ TEST(XlingsIndexRevision, StatusCarriesTheRevision) { // selection keeps the lines that name a rejection and bounds them, so a verbose // child cannot bury the diagnostic they are attached to. -TEST(XlingsStderrTail, KeepsOnlyErrorLevelLines) { +UTest keeps_only_error_level_lines{"XlingsStderrTail/KeepsOnlyErrorLevelLines", [] { auto tail = mcpp::provider::xlings::stderr_error_tail( "[xim] resolving acme.widget\r\n" "downloading 42%\n" "Error: checksum mismatch\n" "hint: E_NETWORK, retry later\n" "done\n"); - EXPECT_EQ(tail, (std::vector{ + expect(eq(tail, (std::vector{ "[xim] resolving acme.widget", "Error: checksum mismatch", - "hint: E_NETWORK, retry later"})); -} + "hint: E_NETWORK, retry later"}))); +}}; -TEST(XlingsStderrTail, KeepsTheLastLinesUpToTheLimit) { +UTest keeps_the_last_lines_up_to_the_limit{"XlingsStderrTail/KeepsTheLastLinesUpToTheLimit", [] { std::string text; for (int i = 0; i < 30; ++i) text += std::format("error {}\n", i); auto tail = mcpp::provider::xlings::stderr_error_tail(text); - ASSERT_EQ(tail.size(), 20u); - EXPECT_EQ(tail.front(), "error 10"); - EXPECT_EQ(tail.back(), "error 29"); - EXPECT_EQ(mcpp::provider::xlings::stderr_error_tail(text, 2), - (std::vector{"error 28", "error 29"})); - EXPECT_TRUE(mcpp::provider::xlings::stderr_error_tail("").empty()); - EXPECT_EQ(mcpp::provider::xlings::stderr_error_tail("error without a newline"), - (std::vector{"error without a newline"})); -} + expect(fatal(eq(tail.size(), 20u))); + expect(eq(tail.front(), "error 10")); + expect(eq(tail.back(), "error 29")); + expect(eq(mcpp::provider::xlings::stderr_error_tail(text, 2), (std::vector{"error 28", "error 29"}))); + expect(mcpp::provider::xlings::stderr_error_tail("").empty()); + expect(eq(mcpp::provider::xlings::stderr_error_tail("error without a newline"), (std::vector{"error without a newline"}))); +}}; // ─── invocation_env and the started xlings (#614) ───────────────────── // @@ -678,21 +676,21 @@ mcpp::provider::xlings::InvocationVar decided(const mcpp::provider::xlings::Env& } // namespace -TEST(XlingsInvocationEnv, GlobalModeIsAnAbsentProjectDirectory) { +UTest global_mode_is_an_absent_project_directory{"XlingsInvocationEnv/GlobalModeIsAnAbsentProjectDirectory", [] { auto global = xlings_env(""); auto scope = decided(global, "XLINGS_PROJECT_DIR"); - ASSERT_EQ(scope.name, "XLINGS_PROJECT_DIR"); - EXPECT_FALSE(scope.present); + expect(fatal(eq(scope.name, "XLINGS_PROJECT_DIR"))); + expect(!scope.present); auto home = decided(global, "XLINGS_HOME"); - ASSERT_EQ(home.name, "XLINGS_HOME"); - EXPECT_TRUE(home.present); - EXPECT_EQ(home.value, global.home.string()); + expect(fatal(eq(home.name, "XLINGS_HOME"))); + expect(bool(home.present)); + expect(eq(home.value, global.home.string())); auto project = xlings_env("proj-dir"); auto projectScope = decided(project, "XLINGS_PROJECT_DIR"); - EXPECT_TRUE(projectScope.present); - EXPECT_EQ(projectScope.value, project.projectDir.string()); -} + expect(bool(projectScope.present)); + expect(eq(projectScope.value, project.projectDir.string())); +}}; // What a started xlings receives, recorded by a stub in place of the binary: // its working directory, then its environment. @@ -721,8 +719,8 @@ Seen started_with(const mcpp::provider::xlings::Env& e) { // front of PATH used to stay in the process on Windows, so every action of a // build that had installed a payload found `xim:llvm`'s `cl`, `link` and `lib` // shims in front of MSVC's tools, while a build that installed nothing did not. -TEST(XlingsInvocationEnv, TheDecisionReachesTheStartedXlingsOnly) { - if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "a POSIX stub records what xlings received"; +UTest the_decision_reaches_the_started_xlings_only{"XlingsInvocationEnv/TheDecisionReachesTheStartedXlingsOnly", [] { + if constexpr (mcpplibs::os::is_windows) skip("a POSIX stub records what xlings received"); namespace env = mcpp::invocation; env::ScopedEnv home("XLINGS_HOME", std::string("shell-home")); env::ScopedEnv subos("XLINGS_ACTIVE_SUBOS", std::string("shell-subos")); @@ -731,59 +729,57 @@ TEST(XlingsInvocationEnv, TheDecisionReachesTheStartedXlingsOnly) { auto global = xlings_env(""); auto g = started_with(global); - EXPECT_EQ(std::filesystem::path(g.cwd), std::filesystem::weakly_canonical(global.home)); - EXPECT_EQ(g.env["XLINGS_HOME"], global.home.string()); - EXPECT_FALSE(g.env.contains("XLINGS_PROJECT_DIR")); - EXPECT_FALSE(g.env.contains("XLINGS_ACTIVE_SUBOS")); + expect(eq(std::filesystem::path(g.cwd), std::filesystem::weakly_canonical(global.home))); + expect(eq(g.env["XLINGS_HOME"], global.home.string())); + expect(!g.env.contains("XLINGS_PROJECT_DIR")); + expect(!g.env.contains("XLINGS_ACTIVE_SUBOS")); const auto bin = (global.home / "subos" / "default" / "bin").string(); - EXPECT_EQ(g.env["PATH"].substr(0, bin.size()), bin) << g.env["PATH"]; + if (auto&& failed = expect(eq(g.env["PATH"].substr(0, bin.size()), bin)); !failed) failed << g.env["PATH"]; auto inProject = xlings_env("proj-dir"); auto p = started_with(inProject); - EXPECT_EQ(p.env["XLINGS_PROJECT_DIR"], inProject.projectDir.string()); - EXPECT_FALSE(p.env.contains("XLINGS_ACTIVE_SUBOS")); + expect(eq(p.env["XLINGS_PROJECT_DIR"], inProject.projectDir.string())); + expect(!p.env.contains("XLINGS_ACTIVE_SUBOS")); - EXPECT_EQ(env::get("PATH"), pathBefore); - EXPECT_EQ(env::get("XLINGS_HOME"), std::optional("shell-home")); - EXPECT_EQ(env::get("XLINGS_ACTIVE_SUBOS"), std::optional("shell-subos")); - EXPECT_EQ(env::get("XLINGS_PROJECT_DIR"), std::optional("prior-project")); -} + expect(eq(env::get("PATH"), pathBefore)); + expect(eq(env::get("XLINGS_HOME"), std::optional("shell-home"))); + expect(eq(env::get("XLINGS_ACTIVE_SUBOS"), std::optional("shell-subos"))); + expect(eq(env::get("XLINGS_PROJECT_DIR"), std::optional("prior-project"))); +}}; // #726: on Windows too the registry's xlings starts in its home, never in the // directory mcpp was started from, so a project's `.xlings.json` cannot put it // in that project's mode. -TEST(XlingsInvocationEnv, TheWindowsPrefixStartsInTheHome) { +UTest the_windows_prefix_starts_in_the_home{"XlingsInvocationEnv/TheWindowsPrefixStartsInTheHome", [] { mcpp::provider::xlings::Env e; e.home = "C:\\Users\\a b\\.mcpp\\registry"; e.binary = "C:\\Users\\a b\\.mcpp\\registry\\bin\\xlings.exe"; - EXPECT_EQ(mcpp::provider::xlings::windows_command_prefix(e), - "cd /d \"C:\\Users\\a b\\.mcpp\\registry\" && " - "\"C:\\Users\\a b\\.mcpp\\registry\\bin\\xlings.exe\""); -} + expect(eq(mcpp::provider::xlings::windows_command_prefix(e), "cd /d \"C:\\Users\\a b\\.mcpp\\registry\" && " + "\"C:\\Users\\a b\\.mcpp\\registry\\bin\\xlings.exe\"")); +}}; -TEST(XlingsInvocationEnv, ThePosixPrefixRendersTheDecision) { - if constexpr (mcpplibs::os::is_windows) GTEST_SKIP() << "the POSIX spelling"; +UTest the_posix_prefix_renders_the_decision{"XlingsInvocationEnv/ThePosixPrefixRendersTheDecision", [] { + if constexpr (mcpplibs::os::is_windows) skip("the POSIX spelling"); auto global = mcpp::provider::xlings::build_command_prefix(xlings_env("")); - EXPECT_NE(global.find("env -u XLINGS_PROJECT_DIR -u XLINGS_ACTIVE_SUBOS PATH="), - std::string::npos) << global; - EXPECT_EQ(global.find("XLINGS_PROJECT_DIR="), std::string::npos) << global; + expect(ne(global.find("env -u XLINGS_PROJECT_DIR -u XLINGS_ACTIVE_SUBOS PATH="), std::string::npos)) << global; + expect(eq(global.find("XLINGS_PROJECT_DIR="), std::string::npos)) << global; auto project = mcpp::provider::xlings::build_command_prefix(xlings_env("/work/proj")); - EXPECT_EQ(project.find("-u XLINGS_PROJECT_DIR"), std::string::npos) << project; - EXPECT_NE(project.find("env -u XLINGS_ACTIVE_SUBOS PATH="), std::string::npos) << project; - EXPECT_NE(project.find("XLINGS_PROJECT_DIR="), std::string::npos) << project; - EXPECT_NE(project.find("/work/proj"), std::string::npos) << project; -} + expect(eq(project.find("-u XLINGS_PROJECT_DIR"), std::string::npos)) << project; + expect(ne(project.find("env -u XLINGS_ACTIVE_SUBOS PATH="), std::string::npos)) << project; + expect(ne(project.find("XLINGS_PROJECT_DIR="), std::string::npos)) << project; + expect(ne(project.find("/work/proj"), std::string::npos)) << project; +}}; // A shell that ran `xlings subos use ` exports XLINGS_ACTIVE_SUBOS, which // names a SubOS of the shell's xlings home and not of mcpp's registry; no // invocation carries it, in either mode. -TEST(XlingsInvocationEnv, TheShellsActiveSubosIsNeverInherited) { +UTest the_shells_active_subos_is_never_inherited{"XlingsInvocationEnv/TheShellsActiveSubosIsNeverInherited", [] { auto global = decided(xlings_env(""), "XLINGS_ACTIVE_SUBOS"); - ASSERT_EQ(global.name, "XLINGS_ACTIVE_SUBOS"); - EXPECT_FALSE(global.present); + expect(fatal(eq(global.name, "XLINGS_ACTIVE_SUBOS"))); + expect(!global.present); auto project = decided(xlings_env("proj-dir"), "XLINGS_ACTIVE_SUBOS"); - ASSERT_EQ(project.name, "XLINGS_ACTIVE_SUBOS"); - EXPECT_FALSE(project.present); + expect(fatal(eq(project.name, "XLINGS_ACTIVE_SUBOS"))); + expect(!project.present); -} +}}; diff --git a/tests/unit/test_xlings_address_set.cpp b/tests/unit/test_xlings_address_set.cpp index 182af0422..942457220 100644 --- a/tests/unit/test_xlings_address_set.cpp +++ b/tests/unit/test_xlings_address_set.cpp @@ -1,8 +1,10 @@ -#include import std; +import mcpplibs.mtest; import mcpp.provider.address_set; +using namespace mcpplibs::mtest; + namespace addrset = mcpp::provider::addrset; namespace { @@ -27,19 +29,19 @@ std::vector addresses(const addrset::Resolution& r) { // The version is a constraint on a package, never part of its name. Two // definitions of this used to coexist: the conditional merge compared the bare // name, the graph split compared the whole address string. -TEST(XlingsIdentity, IsTheNamespaceAndNameAndNeverTheVersion) { - EXPECT_EQ(addrset::package_key("xim:glibc@2.40"), "xim:glibc"); - EXPECT_EQ(addrset::package_key("xim:glibc"), "xim:glibc"); - EXPECT_EQ(addrset::package_key("xim:glibc@>=2.38"), "xim:glibc"); +UTest is_the_namespace_and_name_and_never_the_version{"XlingsIdentity/IsTheNamespaceAndNameAndNeverTheVersion", [] { + expect(eq(addrset::package_key("xim:glibc@2.40"), "xim:glibc")); + expect(eq(addrset::package_key("xim:glibc"), "xim:glibc")); + expect(eq(addrset::package_key("xim:glibc@>=2.38"), "xim:glibc")); // The manifest's default namespace, spelled the same way `parse_xpkg_ref` // spells it: `ninja` and `xim:ninja` name one package. - EXPECT_EQ(addrset::package_key("ninja"), "xim:ninja"); + expect(eq(addrset::package_key("ninja"), "xim:ninja")); // …and a different index is a different package, which the bare-name // definition could not express. - EXPECT_NE(addrset::package_key("scode:cuda"), addrset::package_key("xim:cuda")); -} + expect(ne(addrset::package_key("scode:cuda"), addrset::package_key("xim:cuda"))); +}}; // ─── Adjudication ────────────────────────────────────────────────────────── @@ -47,162 +49,160 @@ TEST(XlingsIdentity, IsTheNamespaceAndNameAndNeverTheVersion) { // the project owns the exact version. Two statements, one install, and no // warning -- a note here would fire on every build of every project that pins a // tool its rule also requires, which is the intended arrangement. -TEST(XlingsUnify, OnePackageYieldsOneAddress) { +UTest one_package_yields_one_address{"XlingsUnify/OnePackageYieldsOneAddress", [] { std::vector claims{ project("xim:cuda-nvcc@13.3.33"), from("mcpp:plugins", "xim:cuda-nvcc@>=12.9.86"), }; auto r = addrset::unify(claims); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_EQ(addresses(*r), (std::vector{"xim:cuda-nvcc@13.3.33"})); - EXPECT_TRUE(r->overrides.empty()); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(eq(addresses(*r), (std::vector{"xim:cuda-nvcc@13.3.33"}))); + expect(r->overrides.empty()); +}}; // …and when the two really do disagree, the note names both sides and says // which was used. Reported, not inferred from an install log. -TEST(XlingsUnify, AResolvedDisagreementNamesBothSidesAndTheWinner) { +UTest a_resolved_disagreement_names_both_sides_and_the_winner{"XlingsUnify/AResolvedDisagreementNamesBothSidesAndTheWinner", [] { std::vector claims{ project("xim:cuda-nvcc@13.3.33"), from("mcpp:plugins", "xim:cuda-nvcc@12.9.86"), }; auto r = addrset::unify(claims); - ASSERT_TRUE(r.has_value()) << r.error(); - ASSERT_EQ(r->overrides.size(), 1u); - EXPECT_NE(r->overrides[0].find("mcpp:plugins"), std::string::npos); - EXPECT_NE(r->overrides[0].find("this project"), std::string::npos); - EXPECT_NE(r->overrides[0].find("12.9.86"), std::string::npos); - EXPECT_NE(r->overrides[0].find("13.3.33"), std::string::npos); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(fatal(eq(r->overrides.size(), 1u))); + expect(ne(r->overrides[0].find("mcpp:plugins"), std::string::npos)); + expect(ne(r->overrides[0].find("this project"), std::string::npos)); + expect(ne(r->overrides[0].find("12.9.86"), std::string::npos)); + expect(ne(r->overrides[0].find("13.3.33"), std::string::npos)); +}}; // A claim naming no version states that the package is wanted and nothing about // which version, so it must not out-rank a floor merely by being nearer. -TEST(XlingsUnify, AnUnversionedClaimAbstainsFromTheVersionQuestion) { +UTest an_unversioned_claim_abstains_from_the_version_question{"XlingsUnify/AnUnversionedClaimAbstainsFromTheVersionQuestion", [] { std::vector claims{ project("xim:cann-toolkit"), from("mcpp:plugins", "xim:cann-toolkit@>=8.5.0"), }; auto r = addrset::unify(claims); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_EQ(addresses(*r), - (std::vector{"xim:cann-toolkit@>=8.5.0"})); - EXPECT_TRUE(r->overrides.empty()); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(eq(addresses(*r), (std::vector{"xim:cann-toolkit@>=8.5.0"}))); + expect(r->overrides.empty()); +}}; -TEST(XlingsUnify, WithNoVersionAnywhereTheBareAddressSurvives) { +UTest with_no_version_anywhere_the_bare_address_survives{"XlingsUnify/WithNoVersionAnywhereTheBareAddressSurvives", [] { std::vector claims{ project("xim:ninja"), from("board:virt", "ninja"), }; auto r = addrset::unify(claims); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_EQ(addresses(*r), (std::vector{"xim:ninja"})); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(eq(addresses(*r), (std::vector{"xim:ninja"}))); +}}; // Order is the order packages were first claimed, so a build's install list // does not permute when an unrelated dependency is added. -TEST(XlingsUnify, KeepsFirstClaimedOrder) { +UTest keeps_first_claimed_order{"XlingsUnify/KeepsFirstClaimedOrder", [] { std::vector claims{ project("xim:b@1.0"), project("xim:a@1.0"), from("dep", "xim:a@>=1.0"), from("dep", "xim:c@1.0"), }; auto r = addrset::unify(claims); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_EQ(addresses(*r), - (std::vector{"xim:b@1.0", "xim:a@1.0", "xim:c@1.0"})); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(eq(addresses(*r), (std::vector{"xim:b@1.0", "xim:a@1.0", "xim:c@1.0"}))); +}}; // ─── Validation ──────────────────────────────────────────────────────────── // The one refusal this module adds. It is a comparison against a chosen // version, not a search for one: nothing here consults which versions exist. -TEST(XlingsUnify, RefusesAPinBelowAStatedFloorAndNamesBothSides) { +UTest refuses_a_pin_below_a_stated_floor_and_names_both_sides{"XlingsUnify/RefusesAPinBelowAStatedFloorAndNamesBothSides", [] { std::vector claims{ project("xim:cann-toolkit@8.0.0"), from("mcpp:plugins", "xim:cann-toolkit@>=8.5.0"), }; auto r = addrset::unify(claims); - ASSERT_FALSE(r.has_value()); - EXPECT_NE(r.error().find("xim:cann-toolkit"), std::string::npos); - EXPECT_NE(r.error().find("8.0.0"), std::string::npos); - EXPECT_NE(r.error().find(">=8.5.0"), std::string::npos); - EXPECT_NE(r.error().find("mcpp:plugins"), std::string::npos); + expect(fatal(!r.has_value())); + expect(ne(r.error().find("xim:cann-toolkit"), std::string::npos)); + expect(ne(r.error().find("8.0.0"), std::string::npos)); + expect(ne(r.error().find(">=8.5.0"), std::string::npos)); + expect(ne(r.error().find("mcpp:plugins"), std::string::npos)); // The way out is in the message, which is what makes the shortcut of not // intersecting constraints an acceptable one. - EXPECT_NE(r.error().find("drop the pin"), std::string::npos); -} + expect(ne(r.error().find("drop the pin"), std::string::npos)); +}}; -TEST(XlingsUnify, AcceptsAPinThatSatisfiesTheFloor) { +UTest accepts_a_pin_that_satisfies_the_floor{"XlingsUnify/AcceptsAPinThatSatisfiesTheFloor", [] { std::vector claims{ project("xim:cann-toolkit@8.5.0"), from("mcpp:plugins", "xim:cann-toolkit@>=8.5.0"), }; auto r = addrset::unify(claims); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_EQ(addresses(*r), (std::vector{"xim:cann-toolkit@8.5.0"})); + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(eq(addresses(*r), (std::vector{"xim:cann-toolkit@8.5.0"}))); // Satisfied is silent: an override note here would fire on every build of // every project that pins a tool its rule also requires, which is the // intended arrangement rather than a problem. - EXPECT_TRUE(r->overrides.empty()); -} + expect(r->overrides.empty()); +}}; // TWO EXACT PINS ARE TWO CHOICES, NOT A VIOLATED REQUIREMENT. Refusing them // would turn every dependency that pinned a tool the project also pins into a // hard failure on upgrade, for a disagreement adjudication already settles. -TEST(XlingsUnify, TwoExactPinsAreAdjudicatedRatherThanRefused) { +UTest two_exact_pins_are_adjudicated_rather_than_refused{"XlingsUnify/TwoExactPinsAreAdjudicatedRatherThanRefused", [] { std::vector claims{ project("xim:cuda-nvcc@13.3.33"), from("mcpp:plugins", "xim:cuda-nvcc@12.9.86"), }; auto r = addrset::unify(claims); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_EQ(addresses(*r), (std::vector{"xim:cuda-nvcc@13.3.33"})); - EXPECT_EQ(r->overrides.size(), 1u); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(eq(addresses(*r), (std::vector{"xim:cuda-nvcc@13.3.33"}))); + expect(eq(r->overrides.size(), 1u)); +}}; // Two dependencies, no project statement: there is no "nearer", so the tie is // broken by first appearance -- and the validation still runs, which is what // keeps an arbitrary tie-break from silently lowering a floor. -TEST(XlingsUnify, TwoDependenciesInConflictAreStillValidated) { +UTest two_dependencies_in_conflict_are_still_validated{"XlingsUnify/TwoDependenciesInConflictAreStillValidated", [] { std::vector ok{ from("dep-a", "xim:tool@2.0.0"), from("dep-b", "xim:tool@>=1.0.0"), }; - ASSERT_TRUE(addrset::unify(ok).has_value()); + expect(fatal(addrset::unify(ok).has_value())); std::vector bad{ from("dep-a", "xim:tool@1.0.0"), from("dep-b", "xim:tool@>=2.0.0"), }; auto r = addrset::unify(bad); - ASSERT_FALSE(r.has_value()); - EXPECT_NE(r.error().find("dep-a"), std::string::npos); - EXPECT_NE(r.error().find("dep-b"), std::string::npos); -} + expect(fatal(!r.has_value())); + expect(ne(r.error().find("dep-a"), std::string::npos)); + expect(ne(r.error().find("dep-b"), std::string::npos)); +}}; // A version this grammar cannot parse is not evidence of a conflict. Refusing // on a spelling nobody can evaluate would be a refusal manufactured from // ignorance -- `8.0.RC1` is a real published version. -TEST(XlingsUnify, AnUnevaluableSpellingIsReportedNotRefused) { +UTest an_unevaluable_spelling_is_reported_not_refused{"XlingsUnify/AnUnevaluableSpellingIsReportedNotRefused", [] { std::vector claims{ project("xim:cann-toolkit@8.0.RC1"), from("mcpp:plugins", "xim:cann-toolkit@>=8.5.0"), }; auto r = addrset::unify(claims); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_EQ(addresses(*r), (std::vector{"xim:cann-toolkit@8.0.RC1"})); - EXPECT_EQ(r->overrides.size(), 1u); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(eq(addresses(*r), (std::vector{"xim:cann-toolkit@8.0.RC1"}))); + expect(eq(r->overrides.size(), 1u)); +}}; // The same spelling twice is one statement, not a disagreement. Without this a // project that is its own runtime owner would warn about itself on every build. -TEST(XlingsUnify, RepeatingOneSpellingIsSilent) { +UTest repeating_one_spelling_is_silent{"XlingsUnify/RepeatingOneSpellingIsSilent", [] { std::vector claims{ project("xim:shaderc@2026.3"), project("xim:shaderc@2026.3"), from("dep", "xim:shaderc@2026.3"), }; auto r = addrset::unify(claims); - ASSERT_TRUE(r.has_value()) << r.error(); - EXPECT_EQ(addresses(*r), (std::vector{"xim:shaderc@2026.3"})); - EXPECT_TRUE(r->overrides.empty()); -} + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(eq(addresses(*r), (std::vector{"xim:shaderc@2026.3"}))); + expect(r->overrides.empty()); +}}; diff --git a/tests/unit/test_xlings_version_pin.cpp b/tests/unit/test_xlings_version_pin.cpp index 6a39f0b4e..59efa3b6e 100644 --- a/tests/unit/test_xlings_version_pin.cpp +++ b/tests/unit/test_xlings_version_pin.cpp @@ -12,45 +12,48 @@ // put a newer xlings there must not be downgraded by an mcpp pinning an older // one, and an unparseable version is not evidence of being behind. -#include #include #include import std; +import mcpplibs.mtest; +import mcpplibs.os.stdio; import mcpp.invocation; import mcpp.fallback.xlings_binary; +using namespace mcpplibs::mtest; + namespace fb = mcpp::fallback; namespace { -TEST(XlingsVersionPin, OlderIsBehind) { - EXPECT_TRUE(fb::version_is_older("2026.8.2.1", "2026.8.6.3")); - EXPECT_TRUE(fb::version_is_older("2026.7.31.3", "2026.8.1.1")); - EXPECT_TRUE(fb::version_is_older("2026.8.6", "2026.8.6.1")); -} +UTest older_is_behind{"XlingsVersionPin/OlderIsBehind", [] { + expect(fb::version_is_older("2026.8.2.1", "2026.8.6.3")); + expect(fb::version_is_older("2026.7.31.3", "2026.8.1.1")); + expect(fb::version_is_older("2026.8.6", "2026.8.6.1")); +}}; -TEST(XlingsVersionPin, EqualIsNotBehind) { - EXPECT_FALSE(fb::version_is_older("2026.8.6.3", "2026.8.6.3")); +UTest equal_is_not_behind{"XlingsVersionPin/EqualIsNotBehind", [] { + expect(!fb::version_is_older("2026.8.6.3", "2026.8.6.3")); // Trailing zeros compare equal, not older. - EXPECT_FALSE(fb::version_is_older("2026.8.6.0", "2026.8.6")); -} + expect(!fb::version_is_older("2026.8.6.0", "2026.8.6")); +}}; // The case that decides whether this is safe to run automatically: a user who // put a NEWER xlings in place keeps it. -TEST(XlingsVersionPin, NewerIsNotDowngraded) { - EXPECT_FALSE(fb::version_is_older("2026.9.1.1", "2026.8.6.3")); - EXPECT_FALSE(fb::version_is_older("2027.1.1.1", "2026.12.31.9")); -} +UTest newer_is_not_downgraded{"XlingsVersionPin/NewerIsNotDowngraded", [] { + expect(!fb::version_is_older("2026.9.1.1", "2026.8.6.3")); + expect(!fb::version_is_older("2027.1.1.1", "2026.12.31.9")); +}}; // A version this code cannot parse says nothing, and "says nothing" must not // be read as "is behind" -- that would delete a working binary on a guess. -TEST(XlingsVersionPin, UnparseableIsNotBehind) { - EXPECT_FALSE(fb::version_is_older("dev", "2026.8.6.3")); - EXPECT_FALSE(fb::version_is_older("2026.8.6.3", "")); - EXPECT_FALSE(fb::version_is_older("", "2026.8.6.3")); - EXPECT_FALSE(fb::version_is_older("2026.8.x", "2026.8.6.3")); -} +UTest unparseable_is_not_behind{"XlingsVersionPin/UnparseableIsNotBehind", [] { + expect(!fb::version_is_older("dev", "2026.8.6.3")); + expect(!fb::version_is_older("2026.8.6.3", "")); + expect(!fb::version_is_older("", "2026.8.6.3")); + expect(!fb::version_is_older("2026.8.x", "2026.8.6.3")); +}}; // The version scheme changed epochs: xlings went 0.4.x -> YYYY.M.D.N. Both // live on the same disk, and a home's system xlings may still be a 0.4.x while @@ -60,11 +63,11 @@ TEST(XlingsVersionPin, UnparseableIsNotBehind) { // 2026.8.2.1 into 0.4.51: older still, and equally missing the very feature // the replacement existed to restore. Being behind the pin justifies looking // for a replacement; it does not justify accepting whatever turns up. -TEST(XlingsVersionPin, DatedSchemeIsNewerThanTheOldOne) { - EXPECT_FALSE(fb::version_is_older("2026.8.2.1", "0.4.51")); - EXPECT_TRUE(fb::version_is_older("0.4.51", "2026.8.2.1")); - EXPECT_TRUE(fb::version_is_older("0.4.51", "0.4.54")); -} +UTest dated_scheme_is_newer_than_the_old_one{"XlingsVersionPin/DatedSchemeIsNewerThanTheOldOne", [] { + expect(!fb::version_is_older("2026.8.2.1", "0.4.51")); + expect(fb::version_is_older("0.4.51", "2026.8.2.1")); + expect(fb::version_is_older("0.4.51", "0.4.54")); +}}; // THE PROBE READS STANDARD OUTPUT, AND ON WINDOWS IT RUNS AT ALL. // @@ -76,7 +79,7 @@ TEST(XlingsVersionPin, DatedSchemeIsNewerThanTheOldOne) { // this test runs a .bat through the real launcher, which is the path that // failed; elsewhere a shell script. Both print a dotted number on stderr first, // which must not be taken for the version. -TEST(XlingsVersionPin, ProbeReadsStandardOutputThroughTheLauncher) { +UTest probe_reads_standard_output_through_the_launcher{"XlingsVersionPin/ProbeReadsStandardOutputThroughTheLauncher", [] { auto dir = std::filesystem::temp_directory_path() / std::format("mcpp probe {}", std::chrono::steady_clock::now().time_since_epoch().count()); std::filesystem::create_directories(dir); @@ -95,10 +98,10 @@ TEST(XlingsVersionPin, ProbeReadsStandardOutputThroughTheLauncher) { std::filesystem::permissions(fake, std::filesystem::perms::owner_all, std::filesystem::perm_options::replace); #endif - EXPECT_EQ(fb::vendored_xlings_version(fake), "2026.1.2.3"); + expect(eq(fb::vendored_xlings_version(fake), "2026.1.2.3")); std::error_code ec; std::filesystem::remove_all(dir, ec); -} +}}; } // namespace @@ -112,45 +115,45 @@ fb::XlingsSource src(std::string v, std::string origin) { } } // namespace -TEST(XlingsSource, TheOverrideIsTakenAsAnExplicitChoice) { +UTest the_override_is_taken_as_an_explicit_choice{"XlingsSource/TheOverrideIsTakenAsAnExplicitChoice", [] { auto c = fb::choose_xlings_source(src("2026.1.1.1", "override"), src("2026.9.1.1", "released"), src("2026.9.9.1", "path")); - ASSERT_TRUE(c); - EXPECT_EQ(c->origin, "override"); -} + expect(fatal(bool(c))); + expect(eq(c->origin, "override")); +}}; -TEST(XlingsSource, TheNewerOfTheReleasedAndThePathCopyIsTaken) { +UTest the_newer_of_the_released_and_the_path_copy_is_taken{"XlingsSource/TheNewerOfTheReleasedAndThePathCopyIsTaken", [] { auto a = fb::choose_xlings_source(std::nullopt, src("2026.9.29.1", "released"), src("2026.9.30.1", "path")); - ASSERT_TRUE(a); - EXPECT_EQ(a->origin, "path"); + expect(fatal(bool(a))); + expect(eq(a->origin, "path")); auto b = fb::choose_xlings_source(std::nullopt, src("2026.9.30.1", "released"), src("2026.9.29.1", "path")); - ASSERT_TRUE(b); - EXPECT_EQ(b->origin, "released"); -} + expect(fatal(bool(b))); + expect(eq(b->origin, "released")); +}}; -TEST(XlingsSource, TheReleasedCopyWinsATieAndAnUnreadablePathCopy) { +UTest the_released_copy_wins_a_tie_and_an_unreadable_path_copy{"XlingsSource/TheReleasedCopyWinsATieAndAnUnreadablePathCopy", [] { auto tie = fb::choose_xlings_source(std::nullopt, src("2026.9.30.1", "released"), src("2026.9.30.1", "path")); - ASSERT_TRUE(tie); - EXPECT_EQ(tie->origin, "released"); + expect(fatal(bool(tie))); + expect(eq(tie->origin, "released")); auto unreadable = fb::choose_xlings_source(std::nullopt, src("2026.9.30.1", "released"), src("", "path")); - ASSERT_TRUE(unreadable); - EXPECT_EQ(unreadable->origin, "released"); + expect(fatal(bool(unreadable))); + expect(eq(unreadable->origin, "released")); auto releasedUnreadable = fb::choose_xlings_source(std::nullopt, src("", "released"), src("2026.9.30.1", "path")); - ASSERT_TRUE(releasedUnreadable); - EXPECT_EQ(releasedUnreadable->origin, "path"); -} + expect(fatal(bool(releasedUnreadable))); + expect(eq(releasedUnreadable->origin, "path")); +}}; -TEST(XlingsSource, AMissingSourceLeavesTheOther) { +UTest a_missing_source_leaves_the_other{"XlingsSource/AMissingSourceLeavesTheOther", [] { auto onlyPath = fb::choose_xlings_source(std::nullopt, std::nullopt, src("1.0", "path")); - ASSERT_TRUE(onlyPath); - EXPECT_EQ(onlyPath->origin, "path"); - EXPECT_FALSE(fb::choose_xlings_source(std::nullopt, std::nullopt, std::nullopt)); -} + expect(fatal(bool(onlyPath))); + expect(eq(onlyPath->origin, "path")); + expect(!fb::choose_xlings_source(std::nullopt, std::nullopt, std::nullopt)); +}}; // THE VERSION IS ASKED ONCE. Asking costs a process of xlings (0.35 s // measured), and every command that loads the configuration asked. Within a @@ -202,43 +205,43 @@ std::string memo_key(const std::filesystem::path& bin) { } } // namespace -TEST(XlingsVersionMemo, AskedOncePerProcessAndStoredForTheNext) { +UTest asked_once_per_process_and_stored_for_the_next{"XlingsVersionMemo/AskedOncePerProcessAndStoredForTheNext", [] { CountingStub stub("2026.1.2.3"); const auto memo = stub.dir / "memo"; - EXPECT_EQ(fb::known_xlings_version(stub.bin, memo), "2026.1.2.3"); - EXPECT_EQ(fb::known_xlings_version(stub.bin, memo), "2026.1.2.3"); - EXPECT_EQ(stub.runs(), 1); + expect(eq(fb::known_xlings_version(stub.bin, memo), "2026.1.2.3")); + expect(eq(fb::known_xlings_version(stub.bin, memo), "2026.1.2.3")); + expect(eq(stub.runs(), 1)); std::ifstream is(memo); std::string line; std::getline(is, line); - EXPECT_EQ(line, memo_key(stub.bin) + "\t2026.1.2.3"); -} + expect(eq(line, memo_key(stub.bin) + "\t2026.1.2.3")); +}}; -TEST(XlingsVersionMemo, AStoredAnswerIsReadWithoutRunningTheBinary) { +UTest a_stored_answer_is_read_without_running_the_binary{"XlingsVersionMemo/AStoredAnswerIsReadWithoutRunningTheBinary", [] { CountingStub stub("2026.1.2.3"); const auto memo = stub.dir / "memo"; { std::ofstream os(memo, std::ios::binary); os << "/elsewhere/xlings\t1\t1\t2020.1.1.1\n" << memo_key(stub.bin) << "\t2026.7.7.7\n"; } - EXPECT_EQ(fb::known_xlings_version(stub.bin, memo), "2026.7.7.7"); - EXPECT_EQ(stub.runs(), 0); -} + expect(eq(fb::known_xlings_version(stub.bin, memo), "2026.7.7.7")); + expect(eq(stub.runs(), 0)); +}}; -TEST(XlingsVersionMemo, ANewFileIsAskedAgain) { +UTest a_new_file_is_asked_again{"XlingsVersionMemo/ANewFileIsAskedAgain", [] { CountingStub stub("2026.1.2.3"); const auto memo = stub.dir / "memo"; - EXPECT_EQ(fb::known_xlings_version(stub.bin, memo), "2026.1.2.3"); + expect(eq(fb::known_xlings_version(stub.bin, memo), "2026.1.2.3")); // An update writes a new file: another size, and another modification time. std::this_thread::sleep_for(std::chrono::milliseconds(20)); stub.write("2026.10.20.30"); - EXPECT_EQ(fb::known_xlings_version(stub.bin, memo), "2026.10.20.30"); - EXPECT_EQ(stub.runs(), 2); + expect(eq(fb::known_xlings_version(stub.bin, memo), "2026.10.20.30")); + expect(eq(stub.runs(), 2)); std::ifstream is(memo); int lines = 0; for (std::string l; std::getline(is, l);) ++lines; - EXPECT_EQ(lines, 1) << "the binary's earlier line is replaced, not kept"; -} + expect(eq(lines, 1)) << "the binary's earlier line is replaced, not kept"; +}}; // ONCE PER PROCESS (mcpp#744). The configuration is loaded from about ten call // sites, and one `mcpp pack` over a workspace printed its note three times per @@ -254,7 +257,7 @@ class ScopedEnv { }; } // namespace -TEST(XlingsAcquire, TheNoteIsStatedOncePerProcess) { +UTest the_note_is_stated_once_per_process{"XlingsAcquire/TheNoteIsStatedOncePerProcess", [] { CountingStub stub("2026.1.1.1"); const auto empty = stub.dir / "empty-path"; std::filesystem::create_directories(empty); @@ -262,17 +265,17 @@ TEST(XlingsAcquire, TheNoteIsStatedOncePerProcess) { // not run from a release layout (`/bin/`). ScopedEnv override_("MCPP_VENDORED_XLINGS", nullptr); ScopedEnv path("PATH", empty.string().c_str()); - testing::internal::CaptureStderr(); + auto stderrCapture = mcpplibs::os::stdio::capture::begin(mcpplibs::os::stdio::stream::error).value(); auto first = fb::acquire_xlings_binary(stub.bin, /*quiet=*/false, "2026.9.30.1"); auto second = fb::acquire_xlings_binary(stub.bin, /*quiet=*/false, "2026.9.30.1"); - const auto err = testing::internal::GetCapturedStderr(); - ASSERT_TRUE(first); - ASSERT_TRUE(second); + const auto err = stderrCapture.text(); + expect(fatal(bool(first))); + expect(fatal(bool(second))); std::size_t notes = 0; for (auto at = err.find("no newer source is available"); at != std::string::npos; at = err.find("no newer source is available", at + 1)) ++notes; - EXPECT_EQ(notes, 1u) << err; - EXPECT_EQ(stub.runs(), 1) << "the second load asked the version again"; -} + expect(eq(notes, 1u)) << err; + expect(eq(stub.runs(), 1)) << "the second load asked the version again"; +}}; diff --git a/tests/unit/test_xpkg_emit.cpp b/tests/unit/test_xpkg_emit.cpp index d335724c7..d42bdd7e1 100644 --- a/tests/unit/test_xpkg_emit.cpp +++ b/tests/unit/test_xpkg_emit.cpp @@ -1,12 +1,14 @@ -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.modgraph.graph; import mcpp.invocation; import mcpp.publish.xpkg_emit; import mcpp.diag; +using namespace mcpplibs::mtest; + using namespace mcpp::publish; namespace { @@ -35,32 +37,32 @@ mcpp::modgraph::Graph minimal_graph() { } // namespace -TEST(XpkgEmit, ContainsRequiredFields) { +UTest contains_required_fields{"XpkgEmit/ContainsRequiredFields", [] { auto m = minimal_manifest(); auto g = minimal_graph(); auto rel = placeholder_release("0.1.0"); auto out = emit_xpkg(m, g, rel); - EXPECT_NE(out.find("AUTO-GENERATED"), std::string::npos); - EXPECT_NE(out.find("name = \"mcpplibs.hello\""), std::string::npos); - EXPECT_NE(out.find("import_std = true"), std::string::npos); - EXPECT_NE(out.find("\"mcpplibs.hello\""), std::string::npos); // module - EXPECT_NE(out.find("manifest = \"mcpp.toml\""), std::string::npos); - EXPECT_NE(out.find("[\"mcpplibs.primitives\"]"), std::string::npos); // dep -} + expect(ne(out.find("AUTO-GENERATED"), std::string::npos)); + expect(ne(out.find("name = \"mcpplibs.hello\""), std::string::npos)); + expect(ne(out.find("import_std = true"), std::string::npos)); + expect(ne(out.find("\"mcpplibs.hello\""), std::string::npos)); // module + expect(ne(out.find("manifest = \"mcpp.toml\""), std::string::npos)); + expect(ne(out.find("[\"mcpplibs.primitives\"]"), std::string::npos)); // dep +}}; -TEST(XpkgEmit, RejectsLuaInjection) { +UTest rejects_lua_injection{"XpkgEmit/RejectsLuaInjection", [] { auto m = minimal_manifest(); m.package.description = "evil\"injection"; auto g = minimal_graph(); auto out = emit_xpkg(m, g, placeholder_release("0.1.0")); // The escaped form must be present, NOT the raw quote. - EXPECT_NE(out.find("evil\\\"injection"), std::string::npos); -} + expect(ne(out.find("evil\\\"injection"), std::string::npos)); +}}; -TEST(XpkgEmit, EscapesControlCharacters) { +UTest escapes_control_characters{"XpkgEmit/EscapesControlCharacters", [] { auto m = minimal_manifest(); auto g = minimal_graph(); @@ -70,60 +72,56 @@ TEST(XpkgEmit, EscapesControlCharacters) { auto out = emit_xpkg(m, g, placeholder_release("0.1.0")); // Backslash → doubled - EXPECT_NE(out.find("a\\\\b"), std::string::npos); + expect(ne(out.find("a\\\\b"), std::string::npos)); // \r → \r escape - EXPECT_NE(out.find("\\rc"), std::string::npos); + expect(ne(out.find("\\rc"), std::string::npos)); // NUL → \0 - EXPECT_NE(out.find("\\0"), std::string::npos); + expect(ne(out.find("\\0"), std::string::npos)); // 0x07 (BEL) → \x07 - EXPECT_NE(out.find("\\x07"), std::string::npos); + expect(ne(out.find("\\x07"), std::string::npos)); // 0x7f (DEL) → \x7f - EXPECT_NE(out.find("\\x7f"), std::string::npos); + expect(ne(out.find("\\x7f"), std::string::npos)); // No literal control bytes leak through - EXPECT_EQ(out.find('\r'), std::string::npos); - EXPECT_EQ(out.find('\0'), std::string::npos); - EXPECT_EQ(out.find('\x07'), std::string::npos); -} + expect(eq(out.find('\r'), std::string::npos)); + expect(eq(out.find('\0'), std::string::npos)); + expect(eq(out.find('\x07'), std::string::npos)); +}}; -TEST(XpkgEmit, ReleaseTarballUrl) { +UTest xpkg_emit_release_tarball_url{"XpkgEmit/ReleaseTarballUrl", [] { using namespace mcpp::publish; - EXPECT_EQ(release_tarball_url("https://github.com/foo/bar", "bar", "0.1.0"), - "https://github.com/foo/bar/releases/download/v0.1.0/bar-0.1.0.tar.gz"); + expect(eq(release_tarball_url("https://github.com/foo/bar", "bar", "0.1.0"), "https://github.com/foo/bar/releases/download/v0.1.0/bar-0.1.0.tar.gz")); // .git suffix gets stripped - EXPECT_EQ(release_tarball_url("https://github.com/foo/bar.git", "bar", "0.1.0"), - "https://github.com/foo/bar/releases/download/v0.1.0/bar-0.1.0.tar.gz"); + expect(eq(release_tarball_url("https://github.com/foo/bar.git", "bar", "0.1.0"), "https://github.com/foo/bar/releases/download/v0.1.0/bar-0.1.0.tar.gz")); // empty repo → empty URL (caller errors out) - EXPECT_EQ(release_tarball_url("", "bar", "0.1.0"), ""); + expect(eq(release_tarball_url("", "bar", "0.1.0"), "")); // non-http(s) repo URL → empty (don't trust ssh:// / git@ for downloads) - EXPECT_EQ(release_tarball_url("git@github.com:foo/bar.git", "bar", "0.1.0"), ""); -} + expect(eq(release_tarball_url("git@github.com:foo/bar.git", "bar", "0.1.0"), "")); +}}; #if !defined(_WIN32) // sha256_of_file uses sha256sum which is not available on Windows -TEST(XpkgEmit, Sha256OfFile) { +UTest xpkg_emit_sha256_of_file{"XpkgEmit/Sha256OfFile", [] { using namespace mcpp::publish; // Empty file → known SHA-256 auto p = std::filesystem::temp_directory_path() / "mcpp_unit_sha_empty"; { std::ofstream(p).close(); } - EXPECT_EQ(sha256_of_file(p), - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + expect(eq(sha256_of_file(p), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")); std::filesystem::remove(p); // Known content auto p2 = std::filesystem::temp_directory_path() / "mcpp_unit_sha_abc"; { std::ofstream os(p2); os << "abc"; } - EXPECT_EQ(sha256_of_file(p2), - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + expect(eq(sha256_of_file(p2), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")); std::filesystem::remove(p2); // Non-existent file → empty string - EXPECT_EQ(sha256_of_file("/no/such/path/zzz"), ""); -} + expect(eq(sha256_of_file("/no/such/path/zzz"), "")); +}}; #endif // !defined(_WIN32) #if defined(__linux__) -TEST(XpkgEmit, Sha256OfFileIgnoresTargetRuntimeLibraryPath) { +UTest sha256_of_file_ignores_target_runtime_library_path{"XpkgEmit/Sha256OfFileIgnoresTargetRuntimeLibraryPath", [] { using namespace mcpp::publish; auto hostile = std::filesystem::temp_directory_path() @@ -137,17 +135,16 @@ TEST(XpkgEmit, Sha256OfFileIgnoresTargetRuntimeLibraryPath) { { mcpp::invocation::ScopedEnv ld("LD_LIBRARY_PATH", hostile.string()); - EXPECT_EQ(sha256_of_file(p), - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + expect(eq(sha256_of_file(p), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")); } std::error_code ec; std::filesystem::remove(p, ec); std::filesystem::remove_all(hostile, ec); -} +}}; #endif // defined(__linux__) -TEST(XpkgEmit, LongBracketSequenceInValueIsHarmless) { +UTest long_bracket_sequence_in_value_is_harmless{"XpkgEmit/LongBracketSequenceInValueIsHarmless", [] { // We emit `"..."` strings, not `[[...]]`, so a literal `]=]` in // user data can't terminate the literal early. Just make sure it // round-trips as escaped content (the `]`/`=` chars are printable @@ -157,8 +154,8 @@ TEST(XpkgEmit, LongBracketSequenceInValueIsHarmless) { auto g = minimal_graph(); m.package.description = "trick: ]==] more stuff"; auto out = emit_xpkg(m, g, placeholder_release("0.1.0")); - EXPECT_NE(out.find("\"trick: ]==] more stuff\""), std::string::npos); -} + expect(ne(out.find("\"trick: ]==] more stuff\""), std::string::npos)); +}}; // ── #630 item 7: an OS-only selector is a platform ────────────────────────── // @@ -184,7 +181,7 @@ std::string block(const std::string& out, std::string_view header) { } // namespace -TEST(XpkgEmit, OsOnlySelectorNamesItsPlatformBlockAndSuppressesTheWarning) { +UTest os_only_selector_names_its_platform_block_and_suppresses_the_warning{"XpkgEmit/OsOnlySelectorNamesItsPlatformBlockAndSuppressesTheWarning", [] { constexpr const char* src = R"( [package] name = "gtkapp" @@ -194,24 +191,24 @@ version = "0.1.0" "xim:gtk4" = "" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << (m ? std::string{} : m.error().format()); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? std::string{} : m.error().format()); auto g = minimal_graph(); mcpp::diag::reset(); auto out = emit_xpkg(*m, g, placeholder_release("0.1.0")); - EXPECT_NE(block(out, "linux = {").find("xim:gtk4"), std::string::npos) + expect(ne(block(out, "linux = {").find("xim:gtk4"), std::string::npos)) << out; - EXPECT_EQ(block(out, "macosx = {").find("xim:gtk4"), std::string::npos); - EXPECT_EQ(block(out, "windows = {").find("xim:gtk4"), std::string::npos); + expect(eq(block(out, "macosx = {").find("xim:gtk4"), std::string::npos)); + expect(eq(block(out, "windows = {").find("xim:gtk4"), std::string::npos)); // No `publish/target-axis-tools` advisory for THIS section: an OS-only // selector is emitted, so there is nothing left to warn about. - EXPECT_EQ(mcpp::diag::count(mcpp::diag::Severity::Warning), 0u); - EXPECT_TRUE(mcpp::diag::records().empty()); -} + expect(eq(mcpp::diag::count(mcpp::diag::Severity::Warning), 0u)); + expect(mcpp::diag::records().empty()); +}}; -TEST(XpkgEmit, NonOsSelectorNamesNoBlockAndStillWarns) { +UTest non_os_selector_names_no_block_and_still_warns{"XpkgEmit/NonOsSelectorNamesNoBlockAndStillWarns", [] { constexpr const char* src = R"( [package] name = "gtkapp" @@ -221,23 +218,23 @@ version = "0.1.0" "xim:gtk4" = "" )"; auto m = mcpp::manifest::parse_string(src); - ASSERT_TRUE(m.has_value()) << (m ? std::string{} : m.error().format()); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << (m ? std::string{} : m.error().format()); auto g = minimal_graph(); mcpp::diag::reset(); auto out = emit_xpkg(*m, g, placeholder_release("0.1.0")); - EXPECT_EQ(block(out, "linux = {").find("xim:gtk4"), std::string::npos); - EXPECT_EQ(block(out, "macosx = {").find("xim:gtk4"), std::string::npos); - EXPECT_EQ(block(out, "windows = {").find("xim:gtk4"), std::string::npos); + expect(eq(block(out, "linux = {").find("xim:gtk4"), std::string::npos)); + expect(eq(block(out, "macosx = {").find("xim:gtk4"), std::string::npos)); + expect(eq(block(out, "windows = {").find("xim:gtk4"), std::string::npos)); // The residual case: the advisory still fires, and its text now also // states that an OS-only selector WOULD have been emitted. - ASSERT_EQ(mcpp::diag::count(mcpp::diag::Severity::Warning), 1u); + expect(fatal(eq(mcpp::diag::count(mcpp::diag::Severity::Warning), 1u))); auto recs = mcpp::diag::records(); - ASSERT_EQ(recs.size(), 1u); - EXPECT_EQ(recs.front().domain, "publish/target-axis-tools"); - EXPECT_NE(recs.front().what.find("xim:gtk4"), std::string::npos); - EXPECT_NE(recs.front().what.find("OS-only selector"), std::string::npos) + expect(fatal(eq(recs.size(), 1u))); + expect(eq(recs.front().domain, "publish/target-axis-tools")); + expect(ne(recs.front().what.find("xim:gtk4"), std::string::npos)); + expect(ne(recs.front().what.find("OS-only selector"), std::string::npos)) << recs.front().what; -} +}}; diff --git a/tests/unit/test_xpkg_host_tools.cpp b/tests/unit/test_xpkg_host_tools.cpp index 8524b69a4..774be9294 100644 --- a/tests/unit/test_xpkg_host_tools.cpp +++ b/tests/unit/test_xpkg_host_tools.cpp @@ -1,10 +1,12 @@ -#include import std; +import mcpplibs.mtest; import mcpp.manifest; import mcpp.manifest.xpkg; import mcpp.toolchain.platform; +using namespace mcpplibs::mtest; + // #355 needed two things from the Form B (xpkg .lua) descriptor parser that it // simply did not read, and neither failed loudly: // @@ -24,7 +26,7 @@ namespace { mcpp::manifest::Manifest parse_or_fail(std::string_view lua) { auto m = mcpp::manifest::synthesize_from_xpkg_lua( lua, "compat.demo", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - EXPECT_TRUE(m.has_value()) << (m ? "" : m.error().message); + if (auto&& failed = expect(m.has_value()); !failed) failed << (m ? "" : m.error().message); return m.value_or(mcpp::manifest::Manifest{}); } @@ -37,7 +39,7 @@ const mcpp::manifest::Target* find_target(const mcpp::manifest::Manifest& m, } // namespace -TEST(XpkgHostTools, TargetsCarryRequiredFeatures) { +UTest targets_carry_required_features{"XpkgHostTools/TargetsCarryRequiredFeatures", [] { auto m = parse_or_fail(R"LUA( package = { spec = "1", name = "demo", namespace = "compat", type = "package", @@ -53,19 +55,18 @@ package = { )LUA"); auto const* lib = find_target(m, "demo"); - ASSERT_NE(lib, nullptr); - EXPECT_EQ(lib->kind, mcpp::manifest::Target::Library); - EXPECT_TRUE(lib->requiredFeatures.empty()); + expect(fatal(ne(lib, nullptr))); + expect(eq(lib->kind, mcpp::manifest::Target::Library)); + expect(lib->requiredFeatures.empty()); auto const* tool = find_target(m, "protoc"); - ASSERT_NE(tool, nullptr); - EXPECT_EQ(tool->kind, mcpp::manifest::Target::Binary); - EXPECT_EQ(tool->main, "src/compiler/main.cc"); - EXPECT_EQ(tool->requiredFeatures, - (std::vector{"protoc", "upb"})); -} + expect(fatal(ne(tool, nullptr))); + expect(eq(tool->kind, mcpp::manifest::Target::Binary)); + expect(eq(tool->main, "src/compiler/main.cc")); + expect(eq(tool->requiredFeatures, (std::vector{"protoc", "upb"}))); +}}; -TEST(XpkgHostTools, DepsAcceptBothAStringAndATable) { +UTest deps_accept_both_a_string_and_a_table{"XpkgHostTools/DepsAcceptBothAStringAndATable", [] { // The string form is the long-standing one and must keep working // unchanged; the table form is what lets a descriptor request a tool. auto m = parse_or_fail(R"LUA( @@ -88,16 +89,16 @@ package = { if (k.find("protobuf") != std::string::npos) pb = &spec; } - ASSERT_NE(zlib, nullptr); - EXPECT_EQ(zlib->version, "1.3.2"); - EXPECT_TRUE(zlib->tools.empty()); + expect(fatal(ne(zlib, nullptr))); + expect(eq(zlib->version, "1.3.2")); + expect(zlib->tools.empty()); - ASSERT_NE(pb, nullptr); - EXPECT_EQ(pb->version, "35.1"); - EXPECT_EQ(pb->tools, (std::vector{"protoc"})); -} + expect(fatal(ne(pb, nullptr))); + expect(eq(pb->version, "35.1")); + expect(eq(pb->tools, (std::vector{"protoc"}))); +}}; -TEST(XpkgHostTools, UnknownDepKeyIsRecordedRatherThanSwallowed) { +UTest unknown_dep_key_is_recorded_rather_than_swallowed{"XpkgHostTools/UnknownDepKeyIsRecordedRatherThanSwallowed", [] { // A descriptor author writing an unsupported key must be told. Silently // ignoring it leaves the dependency half-configured with no signal — the // exact failure the per-feature key recording already exists to prevent. @@ -116,17 +117,17 @@ package = { bool recorded = false; for (auto const& k : m.xpkgUnknownKeys) if (k.find("no_such_key") != std::string::npos) recorded = true; - EXPECT_TRUE(recorded) << "unknown dep key was swallowed"; + expect(bool(recorded)) << "unknown dep key was swallowed"; // ...and the keys it DOES understand still take effect. for (auto const& [k, spec] : m.dependencies) if (k.find("protobuf") != std::string::npos) - EXPECT_EQ(spec.version, "35.1"); -} + expect(eq(spec.version, "35.1")); +}}; // #642 E1: a descriptor's target states its default link form exactly as // `mcpp.toml` does, and the same statements are refused. -TEST(XpkgHostTools, TargetsCarryTheirDefaultLinkForm) { +UTest targets_carry_their_default_link_form{"XpkgHostTools/TargetsCarryTheirDefaultLinkForm", [] { auto m = parse_or_fail(R"LUA( package = { spec = "1", name = "demo", namespace = "compat", type = "package", @@ -139,9 +140,9 @@ package = { } )LUA"); auto const* lib = find_target(m, "demo"); - ASSERT_NE(lib, nullptr); - EXPECT_EQ(lib->kind, mcpp::manifest::Target::Library); - EXPECT_EQ(lib->linkageDefault, "shared"); + expect(fatal(ne(lib, nullptr))); + expect(eq(lib->kind, mcpp::manifest::Target::Library)); + expect(eq(lib->linkageDefault, "shared")); auto refused = mcpp::manifest::synthesize_from_xpkg_lua(R"LUA( package = { @@ -154,7 +155,6 @@ package = { }, } )LUA", "compat.demo", "1.0.0", mcpp::toolchain::platform::HostPlatform::current()); - ASSERT_FALSE(refused.has_value()); - EXPECT_NE(refused.error().message.find("states both `kind = \"shared\"`"), - std::string::npos) << refused.error().message; -} + expect(fatal(!refused.has_value())); + if (auto&& failed = expect(ne(refused.error().message.find("states both `kind = \"shared\"`"), std::string::npos)); !failed) failed << refused.error().message; +}}; diff --git a/tests/unit/test_xpkg_versions_merge.cpp b/tests/unit/test_xpkg_versions_merge.cpp index df62e111d..fc9d6372a 100644 --- a/tests/unit/test_xpkg_versions_merge.cpp +++ b/tests/unit/test_xpkg_versions_merge.cpp @@ -3,69 +3,71 @@ // display keeps the ORIGINAL key byte-for-byte (#363's lesson: an arbitrary // index key cannot be reproduced from its parsed form). -#include import std; +import mcpplibs.mtest; import mcpp.manifest; +using namespace mcpplibs::mtest; + namespace { -TEST(XpkgVersionsMerge, OrdersSemverDescendingAcrossPlatforms) { +UTest orders_semver_descending_across_platforms{"XpkgVersionsMerge/OrdersSemverDescendingAcrossPlatforms", [] { std::vector> perPlatform = { {"1.0.0"}, {"0.9.0", "1.2.0"}, }; auto merged = mcpp::manifest::merge_xpkg_versions_desc(perPlatform); - ASSERT_EQ(merged.size(), 3u); - EXPECT_EQ(merged[0], "1.2.0"); - EXPECT_EQ(merged[1], "1.0.0"); - EXPECT_EQ(merged[2], "0.9.0"); -} + expect(fatal(eq(merged.size(), 3u))); + expect(eq(merged[0], "1.2.0")); + expect(eq(merged[1], "1.0.0")); + expect(eq(merged[2], "0.9.0")); +}}; -TEST(XpkgVersionsMerge, DeduplicatesKeysSharedAcrossPlatforms) { +UTest deduplicates_keys_shared_across_platforms{"XpkgVersionsMerge/DeduplicatesKeysSharedAcrossPlatforms", [] { std::vector> perPlatform = { {"1.0.0", "0.9.0"}, {"1.0.0", "0.8.0"}, {"1.0.0"}, }; auto merged = mcpp::manifest::merge_xpkg_versions_desc(perPlatform); - ASSERT_EQ(merged.size(), 3u); - EXPECT_EQ(merged[0], "1.0.0"); - EXPECT_EQ(merged[1], "0.9.0"); - EXPECT_EQ(merged[2], "0.8.0"); -} + expect(fatal(eq(merged.size(), 3u))); + expect(eq(merged[0], "1.0.0")); + expect(eq(merged[1], "0.9.0")); + expect(eq(merged[2], "0.8.0")); +}}; -TEST(XpkgVersionsMerge, ReleaseOutranksPrereleaseOfSameNumbers) { +UTest release_outranks_prerelease_of_same_numbers{"XpkgVersionsMerge/ReleaseOutranksPrereleaseOfSameNumbers", [] { std::vector> perPlatform = { {"1.0.0-rc.1", "1.0.0"}, }; auto merged = mcpp::manifest::merge_xpkg_versions_desc(perPlatform); - ASSERT_EQ(merged.size(), 2u); - EXPECT_EQ(merged[0], "1.0.0"); - EXPECT_EQ(merged[1], "1.0.0-rc.1"); -} + expect(fatal(eq(merged.size(), 2u))); + expect(eq(merged[0], "1.0.0")); + expect(eq(merged[1], "1.0.0-rc.1")); +}}; -TEST(XpkgVersionsMerge, PrereleaseNumericIdentifiersCompareNumerically) { +UTest prerelease_numeric_identifiers_compare_numerically{"XpkgVersionsMerge/PrereleaseNumericIdentifiersCompareNumerically", [] { std::vector> perPlatform = { {"1.0.0-rc.2", "1.0.0-rc.10"}, }; auto merged = mcpp::manifest::merge_xpkg_versions_desc(perPlatform); - ASSERT_EQ(merged.size(), 2u); - EXPECT_EQ(merged[0], "1.0.0-rc.10"); - EXPECT_EQ(merged[1], "1.0.0-rc.2"); -} + expect(fatal(eq(merged.size(), 2u))); + expect(eq(merged[0], "1.0.0-rc.10")); + expect(eq(merged[1], "1.0.0-rc.2")); +}}; -TEST(XpkgVersionsMerge, FourthSegmentComparesNumerically) { +UTest fourth_segment_compares_numerically{"XpkgVersionsMerge/FourthSegmentComparesNumerically", [] { std::vector> perPlatform = { {"1.2.3.10", "1.2.3.9"}, }; auto merged = mcpp::manifest::merge_xpkg_versions_desc(perPlatform); - ASSERT_EQ(merged.size(), 2u); - EXPECT_EQ(merged[0], "1.2.3.10"); - EXPECT_EQ(merged[1], "1.2.3.9"); -} + expect(fatal(eq(merged.size(), 2u))); + expect(eq(merged[0], "1.2.3.10")); + expect(eq(merged[1], "1.2.3.9")); +}}; -TEST(XpkgVersionsMerge, KeepsOriginalKeyForSuffixedBuilds) { +UTest keeps_original_key_for_suffixed_builds{"XpkgVersionsMerge/KeepsOriginalKeyForSuffixedBuilds", [] { // compat.glad publishes keys like "0.0.0-651a425": the suffix rides along // as pre-release identifiers for ordering, but what reaches the screen is // the key as written. @@ -73,47 +75,44 @@ TEST(XpkgVersionsMerge, KeepsOriginalKeyForSuffixedBuilds) { {"0.0.0-651a425", "0.0.1"}, }; auto merged = mcpp::manifest::merge_xpkg_versions_desc(perPlatform); - ASSERT_EQ(merged.size(), 2u); - EXPECT_EQ(merged[0], "0.0.1"); - EXPECT_EQ(merged[1], "0.0.0-651a425"); -} + expect(fatal(eq(merged.size(), 2u))); + expect(eq(merged[0], "0.0.1")); + expect(eq(merged[1], "0.0.0-651a425")); +}}; -TEST(XpkgVersionsMerge, UnparsableKeysSortLastByNameAndKeepTheirText) { +UTest unparsable_keys_sort_last_by_name_and_keep_their_text{"XpkgVersionsMerge/UnparsableKeysSortLastByNameAndKeepTheirText", [] { std::vector> perPlatform = { {"unknown", "1.0.0", "beta-thing"}, }; auto merged = mcpp::manifest::merge_xpkg_versions_desc(perPlatform); - ASSERT_EQ(merged.size(), 3u); - EXPECT_EQ(merged[0], "1.0.0"); - EXPECT_EQ(merged[1], "beta-thing"); - EXPECT_EQ(merged[2], "unknown"); -} - -TEST(XpkgVersionsMerge, EmptyInputsYieldEmptyOutput) { - EXPECT_TRUE(mcpp::manifest::merge_xpkg_versions_desc({}).empty()); - EXPECT_TRUE( - mcpp::manifest::merge_xpkg_versions_desc({{}, {}}).empty()); -} + expect(fatal(eq(merged.size(), 3u))); + expect(eq(merged[0], "1.0.0")); + expect(eq(merged[1], "beta-thing")); + expect(eq(merged[2], "unknown")); +}}; + +UTest empty_inputs_yield_empty_output{"XpkgVersionsMerge/EmptyInputsYieldEmptyOutput", [] { + expect(mcpp::manifest::merge_xpkg_versions_desc({}).empty()); + expect(mcpp::manifest::merge_xpkg_versions_desc({{}, {}}).empty()); +}}; // A truncated line must say it is truncated. Without the marker the default // three-version view reads as the complete set, which is the one wrong // conclusion the display can produce -- and both `mcpp search` and the // `mcpp add` suggestion render through this function, so one assertion // covers the contract for both. -TEST(JoinVersionsDesc, TruncationIsMarkedAndAnExactFitIsNot) { +UTest truncation_is_marked_and_an_exact_fit_is_not{"JoinVersionsDesc/TruncationIsMarkedAndAnExactFitIsNot", [] { const std::vector six = {"0.0.6", "0.0.5", "0.0.4", "0.0.3", "0.0.2", "0.0.1"}; - EXPECT_EQ(mcpp::manifest::join_versions_desc(six), - "0.0.6, 0.0.5, 0.0.4, ..."); - EXPECT_EQ(mcpp::manifest::join_versions_desc(six, six.size()), - "0.0.6, 0.0.5, 0.0.4, 0.0.3, 0.0.2, 0.0.1"); + expect(eq(mcpp::manifest::join_versions_desc(six), "0.0.6, 0.0.5, 0.0.4, ...")); + expect(eq(mcpp::manifest::join_versions_desc(six, six.size()), "0.0.6, 0.0.5, 0.0.4, 0.0.3, 0.0.2, 0.0.1")); // Exactly `keep` entries is not a truncation. const std::vector three = {"2.0.0", "1.0.0", "0.9.0"}; - EXPECT_EQ(mcpp::manifest::join_versions_desc(three), "2.0.0, 1.0.0, 0.9.0"); + expect(eq(mcpp::manifest::join_versions_desc(three), "2.0.0, 1.0.0, 0.9.0")); - EXPECT_EQ(mcpp::manifest::join_versions_desc({}), ""); - EXPECT_EQ(mcpp::manifest::join_versions_desc({"1.0.0"}), "1.0.0"); -} + expect(eq(mcpp::manifest::join_versions_desc({}), "")); + expect(eq(mcpp::manifest::join_versions_desc({"1.0.0"}), "1.0.0")); +}}; } // namespace From 835509934150dace8321aa43f25c12486c9d9752 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 05:38:31 +0900 Subject: [PATCH 04/45] =?UTF-8?q?target:=20the=20rows=20a=20process=20know?= =?UTF-8?q?s=20and=20the=20selection=20of=20SPEC-016=20=C2=A74?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcpp.toolchain.target_rows holds the built-in rows and the rows a platform defines; the parser accepts a defined os and env, and a defined row answers the triple's format, family, clang triple and naming. mcpp.toolchain.target_select orders the candidates of a request by priority, row and name, keeps those a declared family or a named provider allows, and takes the first available on the host. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- modules/toolchain-model/build.mcpp | 23 ++- modules/toolchain-model/data/builtin.toml | 7 + modules/toolchain-model/src/target_rows.cppm | 143 ++++++++++++++++ .../toolchain-model/src/target_schema.cppm | 2 + .../toolchain-model/src/target_select.cppm | 152 ++++++++++++++++++ modules/toolchain-model/src/triple.cppm | 50 ++++++ .../tests/test_target_select.cpp | 126 +++++++++++++++ 7 files changed, 500 insertions(+), 3 deletions(-) create mode 100644 modules/toolchain-model/src/target_rows.cppm create mode 100644 modules/toolchain-model/src/target_select.cppm create mode 100644 modules/toolchain-model/tests/test_target_select.cpp diff --git a/modules/toolchain-model/build.mcpp b/modules/toolchain-model/build.mcpp index 17d5e6a9b..b4b5b5e2d 100644 --- a/modules/toolchain-model/build.mcpp +++ b/modules/toolchain-model/build.mcpp @@ -15,7 +15,7 @@ namespace toml = mcpp::libs::toml; namespace { constexpr std::string_view kRowKeys[] = { - "triple", "tier", "note", "standard", "march", "mabi", "code-model", "multilib", "flags", + "triple", "tier", "note", "standard", "exact", "march", "mabi", "code-model", "multilib", "flags", "link-emulation", }; constexpr std::string_view kProviderKeys[] = { @@ -90,10 +90,27 @@ std::string row_entry(const toml::Table& t, Arrays& arrays, std::setsecond.is_bool()) throw Failure{std::format("{}: `standard` is not a boolean", where)}; standard = it->second.as_bool(); } + // The segments of the canonical spelling: `arch-os` or `arch-os-env`. + std::vector seg; + for (std::size_t b = 0;;) { + const auto d = triple.find('-', b); + seg.push_back(triple.substr(b, d == std::string::npos ? std::string::npos : d - b)); + if (d == std::string::npos) break; + b = d + 1; + } + if (seg.size() < 2 || seg.size() > 3 || std::ranges::any_of(seg, [](auto const& x) { return x.empty(); })) + throw Failure{std::format("{}: a triple is `arch-os` or `arch-os-env`", where)}; + bool exact = false; + if (auto it = t.find("exact"); it != t.end()) { + if (!it->second.is_bool()) throw Failure{std::format("{}: `exact` is not a boolean", where)}; + exact = it->second.as_bool(); + } return std::format( - " {{ .triple = {}, .tier = {}, .note = {}, .standard = {}, .march = {}, .mabi = {}, " + " {{ .triple = {}, .arch = {}, .os = {}, .env = {}, .tier = {}, .note = {}, .standard = {}, " + ".exact = {}, .march = {}, .mabi = {}, " ".codeModel = {}, .multilib = {}, .flags = {}, .linkEmulation = {} }},\n", - lit(triple), lit(tier), lit(text(t, "note", where)), standard ? "true" : "false", + lit(triple), lit(seg[0]), lit(seg[1]), lit(seg.size() == 3 ? seg[2] : std::string{}), lit(tier), + lit(text(t, "note", where)), standard ? "true" : "false", exact ? "true" : "false", lit(text(t, "march", where)), lit(text(t, "mabi", where)), lit(text(t, "code-model", where)), lit(text(t, "multilib", where)), arrays.add(words(t, "flags", where)), lit(text(t, "link-emulation", where))); diff --git a/modules/toolchain-model/data/builtin.toml b/modules/toolchain-model/data/builtin.toml index 8b8e7afd7..464d9e4aa 100644 --- a/modules/toolchain-model/data/builtin.toml +++ b/modules/toolchain-model/data/builtin.toml @@ -24,6 +24,8 @@ # note the display note of `mcpp toolchain list` # standard the identity is an industry-standard one; a combination that # is not (musl on Windows) is mcpp's name for what is produced +# exact only a request that names the row whole reaches it; `arch-os` +# does not # march, mabi, code-model, multilib, flags, link-emulation # the ISA profile of a bare-metal row: `-march`, `-mabi`, # `-mcmodel` (empty: the driver's default), the multilib @@ -284,16 +286,21 @@ march = "i686" link-emulation = "elf_i386" # ── Android, iOS, the Web, ok images ───────────────────────────────────────── +# +# Android is Linux with bionic; `x86_64-linux` asks for a C library of Linux, not +# for an Android application, so the Android rows are reached by name only. [[platform.define]] triple = "aarch64-linux-android" tier = "verified" standard = true +exact = true [[platform.define]] triple = "x86_64-linux-android" tier = "verified" standard = true +exact = true [[platform.define]] triple = "aarch64-ios" diff --git a/modules/toolchain-model/src/target_rows.cppm b/modules/toolchain-model/src/target_rows.cppm new file mode 100644 index 000000000..1c944e3c3 --- /dev/null +++ b/modules/toolchain-model/src/target_rows.cppm @@ -0,0 +1,143 @@ +// mcpp.toolchain.target_rows — the rows and providers a process knows. +// +// The built-in table (data/builtin.toml) and the definitions of the platforms a +// build has read (`[[platform.define]]`, `[[platform.provider]]`; SPEC-016 §2, +// §3). A platform's rows extend the vocabulary for every later question in the +// process: the triple parser accepts the os and env a defined row names, and +// `cfg(os = ...)` evaluates against the row. A definition stays for the process, +// because one process plans one build and the configurations it derives from it. + +export module mcpp.toolchain.target_rows; + +import std; +import mcpp.toolchain.target_schema; +import mcpp.toolchain.builtin_targets; + +export namespace mcpp::toolchain::target { + +// THE STRINGS A DEFINITION OWNS. A row and a provider view strings; a defined +// one views the strings of the set that read it, which lives as long as the +// process does once it is defined. +struct DefinitionSet { + std::string source; // the platform package that stated it + std::deque strings; // stable addresses + std::deque> lists; + std::vector rows; + std::vector providers; + + std::string_view keep(std::string s) { return strings.emplace_back(std::move(s)); } + std::span keep(const std::vector& v) { + auto& out = lists.emplace_back(); + for (auto const& s : v) out.push_back(keep(s)); + return out; + } +}; + +// Registers a platform's definitions. A row the table or an earlier set already +// defines is not defined again; the answer is the first definition's. +void define(DefinitionSet set); + +// Every row: the built-in ones in the table's order, then the defined ones. +std::vector rows(); +std::vector providers(); +const Row* find_row(std::string_view triple); + +// Whether a defined row names this os, or this env under this os: the words the +// parser accepts beyond its own. +bool defines_os(std::string_view os); +bool defines_env(std::string_view os, std::string_view env); + +// The row a platform defined for this triple; null when none did (the cheap +// answer while nothing is defined, which is every build without such a platform). +const Row* defined_row(std::string_view triple); + +} // namespace mcpp::toolchain::target + +namespace mcpp::toolchain::target { + +namespace { + +struct Registry { + std::mutex lock; + std::deque sets; // stable addresses +}; + +std::atomic g_anyDefined{false}; + +Registry& registry() { + static Registry r; + return r; +} + +} // namespace + +void define(DefinitionSet set) { + auto& r = registry(); + std::lock_guard g(r.lock); + std::erase_if(set.rows, [&](const Row& row) { + for (auto const& b : mcpp::toolchain::builtin::kRows) + if (b.triple == row.triple) return true; + for (auto const& s : r.sets) + for (auto const& d : s.rows) + if (d.triple == row.triple) return true; + return false; + }); + r.sets.push_back(std::move(set)); + g_anyDefined = true; +} + +const Row* defined_row(std::string_view triple) { + if (!g_anyDefined) return nullptr; + auto& r = registry(); + std::lock_guard g(r.lock); + for (auto const& s : r.sets) + for (auto const& d : s.rows) + if (d.triple == triple) return &d; + return nullptr; +} + +std::vector rows() { + std::vector out; + for (auto const& b : mcpp::toolchain::builtin::kRows) out.push_back(&b); + auto& r = registry(); + std::lock_guard g(r.lock); + for (auto const& s : r.sets) + for (auto const& d : s.rows) out.push_back(&d); + return out; +} + +std::vector providers() { + std::vector out; + for (auto const& b : mcpp::toolchain::builtin::kProviders) out.push_back(&b); + auto& r = registry(); + std::lock_guard g(r.lock); + for (auto const& s : r.sets) + for (auto const& p : s.providers) out.push_back(&p); + return out; +} + +const Row* find_row(std::string_view triple) { + for (auto const* r : rows()) + if (r->triple == triple) return r; + return nullptr; +} + +bool defines_os(std::string_view os) { + auto& r = registry(); + std::lock_guard g(r.lock); + for (auto const& s : r.sets) + for (auto const& d : s.rows) + if (d.os == os) return true; + return false; +} + +bool defines_env(std::string_view os, std::string_view env) { + auto& r = registry(); + std::lock_guard g(r.lock); + for (auto const& s : r.sets) + for (auto const& d : s.rows) + if (d.os == os && d.env == env) return true; + return false; +} + +} // namespace mcpp::toolchain::target diff --git a/modules/toolchain-model/src/target_schema.cppm b/modules/toolchain-model/src/target_schema.cppm index 5bddc6424..9ed998e07 100644 --- a/modules/toolchain-model/src/target_schema.cppm +++ b/modules/toolchain-model/src/target_schema.cppm @@ -19,9 +19,11 @@ export namespace mcpp::toolchain::target { // platform defines -- everything a build of an unfamiliar system needs. struct Row { std::string_view triple; // canonical `arch-os[-env]` + std::string_view arch, os, env; // its segments std::string_view tier; // verified | preview | planned std::string_view note; // display note bool standard = true; // an industry-standard identity + bool exact = false; // reached only by a request that names it whole std::string_view march; // -march= of a bare-metal row std::string_view mabi; // -mabi=; empty: none given std::string_view codeModel; // -mcmodel=; empty: the driver's diff --git a/modules/toolchain-model/src/target_select.cppm b/modules/toolchain-model/src/target_select.cppm new file mode 100644 index 000000000..f2168dcda --- /dev/null +++ b/modules/toolchain-model/src/target_select.cppm @@ -0,0 +1,152 @@ +// mcpp.toolchain.target_select — from a request to an identity and its provider. +// +// THE ONE RULE OF SPEC-016 §4. Explicit choices win: the caller passes a named +// provider or a declared compiler family, and only candidates that agree with +// them remain. Otherwise the candidates of the request -- each row the request +// matches, with each provider that serves it -- are ordered by priority (larger +// first), then by row, then by provider name, and the first one available on +// this host is taken. Nothing here names a system, a C library or a provider: +// the rows and providers are data (mcpp.toolchain.target_rows), and so are the +// priorities, which a project or a machine may change. +// +// A request names a pattern. One that writes an env segment matches that row +// only; one that writes `arch-os` matches every row of that system except those +// reached only by name (`exact`), so `x86_64-windows` is the MSVC, MinGW and +// musl identities and `aarch64-macos` is both the native identity and its musl +// sibling. When no candidate is available on the host the first is taken +// anyway, unavailable, and what refuses it is the toolchain or target-side step +// that finds the missing piece, as before a provider was a concept. + +export module mcpp.toolchain.target_select; + +import std; +import mcpp.toolchain.target_schema; +import mcpp.toolchain.target_rows; + +export namespace mcpp::toolchain::target { + +struct Candidate { + const Row* row = nullptr; + const Provider* provider = nullptr; // null: no provider serves the row + int priority = 0; // after the overrides + bool available = false; + std::string why; // why it is not available here +}; + +struct Context { + std::string host; // `-` of the build machine + // Answers an engine probe a provider names (`msvc`). + std::function probe; + // A project's or a machine's priority for a provider on a row, if one is set. + std::function(const Row&, std::string_view provider)> priority; + // A declared compiler family: only providers that pair with it serve. + std::string family; + // A provider chosen by name (`builtin:`): only it serves. + std::string provider; +}; + +struct Selection { + std::string request; // as written + const Row* row = nullptr; // the identity + const Provider* provider = nullptr; + int priority = 0; + bool fallback = false; // the first candidate is not available here + std::vector candidates; // in order + std::string identity() const { return row ? std::string(row->triple) : std::string{}; } +}; + +// The rows a request reaches, in the table's order. `arch`, `os`, `env` are the +// request's canonical segments; `envWritten` whether the request wrote the env. +std::vector rows_of(std::string_view arch, std::string_view os, std::string_view env, + bool envWritten); + +// The selection. Empty `candidates` when the request reaches no row. +Selection select(std::string_view request, std::string_view arch, std::string_view os, + std::string_view env, bool envWritten, const Context& ctx); + +// `-` of this machine, in the words `hosts` uses. +std::string host_pair(std::string_view os, std::string_view arch); + +} // namespace mcpp::toolchain::target + +namespace mcpp::toolchain::target { + +std::vector rows_of(std::string_view arch, std::string_view os, std::string_view env, + bool envWritten) { + std::vector out; + for (auto const* r : rows()) { + if (r->arch != arch || r->os != os) continue; + if (envWritten ? r->env != env : (r->exact && r->env != env)) continue; + out.push_back(r); + } + return out; +} + +std::string host_pair(std::string_view os, std::string_view arch) { + return std::format("{}-{}", os, arch); +} + +Selection select(std::string_view request, std::string_view arch, std::string_view os, + std::string_view env, bool envWritten, const Context& ctx) { + Selection s; + s.request = std::string(request); + const auto reached = rows_of(arch, os, env, envWritten); + // A planned row is a candidate only when the request reaches nothing else: + // it is then the answer, and the tier check refuses it by name. + const bool onlyPlanned = std::ranges::all_of(reached, [](const Row* r) { return r->tier == "planned"; }); + const auto all = providers(); + for (std::size_t i = 0; i < reached.size(); ++i) { + const Row& row = *reached[i]; + if (row.tier == "planned" && !onlyPlanned) continue; + bool served = false; + for (auto const* p : all) { + if (!serves(*p, row.triple)) continue; + if (!ctx.provider.empty() && p->name != ctx.provider) continue; + served = true; + Candidate c{ &row, p, p->priority, true, {} }; + if (ctx.priority) + if (auto n = ctx.priority(row, p->name)) c.priority = *n; + if (!ctx.family.empty() && !p->pairs.empty() + && std::ranges::find(p->pairs, ctx.family) == p->pairs.end()) { + c.available = false; + std::string pairs; + for (auto f : p->pairs) pairs += std::format("{}{}", pairs.empty() ? "" : ", ", f); + c.why = std::format("{} pairs with {}, not {}", p->name, pairs, ctx.family); + } else if (!available_on(*p, ctx.host, row.arch)) { + c.available = false; + c.why = std::format("{} is not available on {}", p->name, ctx.host); + } else if (!p->probe.empty() && !(ctx.probe && ctx.probe(p->probe))) { + c.available = false; + c.why = std::format("{} is not installed here", p->name); + } else if (row.tier == "planned") { + c.available = false; + c.why = std::format("{} is planned", row.triple); + } + s.candidates.push_back(std::move(c)); + } + // A row no provider serves (a platform's row before its provider is + // read, or a provider chosen by name that does not serve it) is still a + // candidate when nothing was chosen by name: the platform serves it. + if (!served && ctx.provider.empty()) + s.candidates.push_back(Candidate{ &row, nullptr, 0, true, {} }); + } + std::ranges::stable_sort(s.candidates, [&](const Candidate& a, const Candidate& b) { + if (a.priority != b.priority) return a.priority > b.priority; + const auto ra = std::ranges::find(reached, a.row) - reached.begin(); + const auto rb = std::ranges::find(reached, b.row) - reached.begin(); + if (ra != rb) return ra < rb; + const auto na = a.provider ? a.provider->name : std::string_view{}; + const auto nb = b.provider ? b.provider->name : std::string_view{}; + return na < nb; + }); + if (s.candidates.empty()) return s; + auto chosen = std::ranges::find_if(s.candidates, [](const Candidate& c) { return c.available; }); + if (chosen == s.candidates.end()) chosen = s.candidates.begin(); + s.row = chosen->row; + s.provider = chosen->provider; + s.priority = chosen->priority; + s.fallback = chosen != s.candidates.begin(); + return s; +} + +} // namespace mcpp::toolchain::target diff --git a/modules/toolchain-model/src/triple.cppm b/modules/toolchain-model/src/triple.cppm index aba05d67e..177e950d6 100644 --- a/modules/toolchain-model/src/triple.cppm +++ b/modules/toolchain-model/src/triple.cppm @@ -28,6 +28,7 @@ import std; import mcpplibs.os.platform; import mcpp.toolchain.platform; import mcpp.toolchain.target_schema; +import mcpp.toolchain.target_rows; import mcpp.toolchain.builtin_targets; export namespace mcpp::toolchain::triple { @@ -153,6 +154,13 @@ struct Triple { // would let a caller supply one platform's answer for the other's. std::string llvm_triple(std::string_view minPlatformVersion = {}) const { if (empty()) return {}; + // A ROW A PLATFORM DEFINES STATES ITS OWN: `clang-triple`, with + // `{version}` the project's minimum system version and + // `{version:}` the row's when the project states none. + if (auto* d = defined()) { + if (d->clangTriple.empty()) return str(); + return expand_version(d->clangTriple, minPlatformVersion); + } if (os == "macos") { // Apple spells the 64-bit ARM architecture `arm64`, and the OS // component carries the deployment target: `arm64-apple-macos14`. @@ -250,6 +258,12 @@ struct Triple { // fallback would otherwise claim ELF for it -- the silent wrong answer this // whole axis exists to remove. ObjectFormat object_format() const { + if (auto* d = defined()) { + if (d->objectFormat == "pe") return ObjectFormat::Pe; + if (d->objectFormat == "mach-o") return ObjectFormat::MachO; + if (d->objectFormat == "wasm") return ObjectFormat::Wasm; + return ObjectFormat::Elf; + } if (os == "windows") return ObjectFormat::Pe; if (os == "macos" || os == "ios") return ObjectFormat::MachO; if (arch.starts_with("wasm")) return ObjectFormat::Wasm; @@ -389,6 +403,7 @@ struct Triple { // nothing, unchanged: `cfg(unix)` on bare metal would be false in a way no // source could act on. std::string family() const { + if (auto* d = defined()) return std::string(d->family); if (os == "windows") return "windows"; if (os == "linux" || os == "macos" || os == "ios" || os == "emscripten") return "unix"; @@ -439,6 +454,24 @@ struct Triple { // unequal to the same triple derived by `host_triple`, and the first thing // that breaks is the `host` tag in `mcpp toolchain list`, which compares // exactly those two. + // The row a platform defined for this triple, if one did (SPEC-016 §2): + // its facts answer where the engine's conventions for a system know none. + const mcpp::toolchain::target::Row* defined() const { + return mcpp::toolchain::target::defined_row(str()); + } + + static std::string expand_version(std::string_view pattern, std::string_view version) { + std::string out(pattern); + const auto at = out.find("{version"); + if (at == std::string::npos) return out; + const auto end = out.find('}', at); + if (end == std::string::npos) return out; + std::string fallback; + if (out[at + 8] == ':') fallback = out.substr(at + 9, end - at - 9); + out.replace(at, end - at + 1, version.empty() ? fallback : std::string(version)); + return out; + } + bool operator==(const Triple& o) const { return arch == o.arch && os == o.os && env == o.env; } @@ -847,6 +880,17 @@ struct ArtifactNaming { // the decision testable from any host (and keeps this module dependency-free). inline ArtifactNaming artifact_naming(const Triple& t, const ArtifactNaming& hostNaming) { if (t.empty()) return hostNaming; + if (auto* d = t.defined()) { + const bool pe = t.is_pe(); + return ArtifactNaming{ + .exeSuffix = d->exeSuffix, .libPrefix = "lib", + .staticLibExt = ".a", + .sharedLibExt = !d->sharedSuffix.empty() ? d->sharedSuffix + : pe ? std::string_view(".dll") + : t.is_mach_o() ? std::string_view(".dylib") : std::string_view(".so"), + .sharedNeedsImportLib = pe, + }; + } if (t.os == "windows") { // PE. The static-library convention splits on env, not on os. @@ -1156,6 +1200,12 @@ std::optional parse(std::string_view s) { // openkal-musl rather than libSystem). The native identity keeps no env // segment; this one names the C library it has. if (t.os == "macos" && k == "musl") { t.env = "musl"; t.envExplicit = true; continue; } + // A SYSTEM A PLATFORM DEFINES (mcpp#911 G1; SPEC-016 §2): the os and + // env words of the rows a platform read in this process states. + if (!sawOs && mcpp::toolchain::target::defines_os(k)) { t.os = std::string(k); sawOs = true; continue; } + if (sawOs && t.env.empty() && mcpp::toolchain::target::defines_env(t.os, k)) { + t.env = std::string(k); t.envExplicit = true; continue; + } // Unrecognized segment (wasi, …): not in mcpp's target language — // treat as unparseable rather than guessing. return std::nullopt; diff --git a/modules/toolchain-model/tests/test_target_select.cpp b/modules/toolchain-model/tests/test_target_select.cpp new file mode 100644 index 000000000..28319f87b --- /dev/null +++ b/modules/toolchain-model/tests/test_target_select.cpp @@ -0,0 +1,126 @@ +// The selection rule of SPEC-016 §4 over the built-in rows and providers: a +// request is a pattern, explicit choices win, and the candidates are ordered by +// priority and taken by availability on the host. The host is a parameter here, +// so every host's answer is stated on every host. +import std; +import mcpplibs.mtest; +import mcpp.toolchain.target_schema; +import mcpp.toolchain.target_select; + +using namespace mcpplibs::mtest; +namespace target = mcpp::toolchain::target; + +namespace { + +struct Ask { + std::string_view request, arch, os, env; + bool envWritten = false; +}; + +target::Selection on(std::string_view host, Ask a, target::Context ctx = {}) { + ctx.host = std::string(host); + return target::select(a.request, a.arch, a.os, a.env, a.envWritten, ctx); +} + +std::string provider_of(const target::Selection& s) { + return s.provider ? std::string(s.provider->name) : std::string("-"); +} + +const Ask kLinux{"x86_64-linux", "x86_64", "linux", "gnu"}; +const Ask kWindows{"x86_64-windows", "x86_64", "windows", "gnu"}; +const Ask kMacos{"aarch64-macos", "aarch64", "macos", ""}; + +} // namespace + +UTest a_pattern_takes_the_hosts_own_provider{"TargetSelect/APatternTakesTheHostsOwnProvider", [] { + auto s = on("linux-x86_64", kLinux); + expect(eq(s.identity(), "x86_64-linux-gnu")); + expect(eq(provider_of(s), "glibc")); + expect(!s.fallback); + auto m = on("macos-aarch64", kMacos); + expect(eq(m.identity(), "aarch64-macos")); + expect(eq(provider_of(m), "apple")); +}}; + +UTest a_pattern_falls_back_to_the_next_available{"TargetSelect/APatternFallsBackToTheNextAvailable", [] { + // No glibc and no musl-gcc payload exists for a macOS host: openkal-platform + // serves the musl identity there, and the selection says it fell back. + auto s = on("macos-aarch64", kLinux); + expect(eq(s.identity(), "x86_64-linux-musl")); + expect(eq(provider_of(s), "openkal")); + expect(s.fallback); + auto m = on("linux-x86_64", kMacos); + expect(eq(m.identity(), "aarch64-macos-musl")); + expect(eq(provider_of(m), "openkal")); +}}; + +UTest windows_takes_msvc_then_openkal_then_mingw{"TargetSelect/WindowsTakesMsvcThenOpenkalThenMingw", [] { + target::Context withMsvc; + withMsvc.probe = [](std::string_view p) { return p == "msvc"; }; + expect(eq(on("windows-x86_64", kWindows, withMsvc).identity(), "x86_64-windows-msvc")); + // Q4: a Windows host without MSVC, and every other host, take the openkal + // platform, whose priority is above MinGW's. + expect(eq(on("windows-x86_64", kWindows).identity(), "x86_64-windows-musl")); + auto l = on("linux-x86_64", kWindows); + expect(eq(l.identity(), "x86_64-windows-musl")); + expect(eq(provider_of(l), "openkal")); +}}; + +UTest a_declared_family_keeps_the_providers_it_pairs_with{"TargetSelect/ADeclaredFamilyKeepsTheProvidersItPairsWith", [] { + target::Context gcc; + gcc.family = "gcc"; + auto s = on("linux-x86_64", kWindows, gcc); + expect(eq(s.identity(), "x86_64-windows-gnu")); + expect(eq(provider_of(s), "mingw")); + target::Context llvm; + llvm.family = "llvm"; + auto g = on("linux-x86_64", {"x86_64-windows-gnu", "x86_64", "windows", "gnu", true}, llvm); + expect(eq(g.identity(), "x86_64-windows-gnu")); + expect(eq(provider_of(g), "mingw")); +}}; + +UTest an_explicit_identity_is_not_replaced{"TargetSelect/AnExplicitIdentityIsNotReplaced", [] { + const Ask musl{"x86_64-linux-musl", "x86_64", "linux", "musl", true}; + expect(eq(provider_of(on("linux-x86_64", musl)), "musl-gcc")); + auto m = on("macos-aarch64", musl); + expect(eq(m.identity(), "x86_64-linux-musl")); + expect(eq(provider_of(m), "openkal")); + const Ask gnu{"x86_64-windows-gnu", "x86_64", "windows", "gnu", true}; + expect(eq(on("linux-x86_64", gnu).identity(), "x86_64-windows-gnu")); +}}; + +UTest a_provider_chosen_by_name_serves_alone{"TargetSelect/AProviderChosenByNameServesAlone", [] { + target::Context mingw; + mingw.provider = "mingw"; + expect(eq(on("linux-x86_64", kWindows, mingw).identity(), "x86_64-windows-gnu")); + target::Context none; + none.provider = "emsdk"; + expect(on("linux-x86_64", kWindows, none).candidates.empty()); +}}; + +UTest a_priority_is_data_a_project_may_change{"TargetSelect/APriorityIsDataAProjectMayChange", [] { + target::Context ctx; + ctx.priority = [](const target::Row&, std::string_view p) -> std::optional { + if (p == "mingw") return 200; + return std::nullopt; + }; + auto s = on("linux-x86_64", kWindows, ctx); + expect(eq(s.identity(), "x86_64-windows-gnu")); + expect(eq(s.priority, 200)); +}}; + +UTest android_is_reached_by_name_only{"TargetSelect/AndroidIsReachedByNameOnly", [] { + for (auto const& c : on("linux-x86_64", kLinux).candidates) + expect(eq(c.row->env == "android", false)) << c.row->triple; + const Ask android{"x86_64-linux-android", "x86_64", "linux", "android", true}; + expect(eq(on("linux-x86_64", android).identity(), "x86_64-linux-android")); +}}; + +UTest a_planned_row_is_the_answer_only_when_alone{"TargetSelect/APlannedRowIsTheAnswerOnlyWhenAlone", [] { + auto s = on("linux-x86_64", {"riscv64-linux", "riscv64", "linux", "gnu"}); + expect(fatal(s.row != nullptr)); + expect(eq(s.identity(), "riscv64-linux-musl")); + expect(eq(s.row->tier, "planned")); + // x86_64-macos is planned; the musl identity beside it is not. + expect(eq(on("macos-x86_64", {"x86_64-macos", "x86_64", "macos", ""}).identity(), "x86_64-macos-musl")); +}}; From 55584d0d6c6899a62bcecd77d15d31ae17ce4aab Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 05:52:51 +0900 Subject: [PATCH 05/45] docs: the target-model design, with the test and build infrastructure and the task plan Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../docs/2026-11-09-target-model-design.md | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 .agents/docs/2026-11-09-target-model-design.md diff --git a/.agents/docs/2026-11-09-target-model-design.md b/.agents/docs/2026-11-09-target-model-design.md new file mode 100644 index 000000000..7a5a893bd --- /dev/null +++ b/.agents/docs/2026-11-09-target-model-design.md @@ -0,0 +1,389 @@ +--- +subject: target-model +status: accepted +--- + +# 目标与提供者:统一的目标模型(结合 mcpp#911) + +**日期:** 2026-11-09 +**状态:** 定稿(2026-11-09):Q1–Q7、R1–R8、第 15 节与第 17 节 T1–T4 已确认;T5、T6 待确认;第 16 节为综合自审,第 18 节为任务与依赖 +**范围:** mcpp(目标的身份、行、提供者与选择;新 SPEC-016 及 SPEC-002、SPEC-013、SPEC-015 的修订);mcpp-index 描述符的 +匹配;openkal-image(openkal-run 的能力槽) +**输入:** 2026-11-09 关于交叉构建的讨论;mcpp#911(移植 ChenPi11/cmd 到 ok 映像、FreeBSD 与 i386 时测得的 G1–G12 与 B1–B3) + +## 1. 问题 + +SPEC-013 §0 规定引擎不持有任何平台的知识,§14 又规定宿主目标的约定留在引擎中、无平台包。两种体系因此并存:openkal 的目标由 +包定义,带版本、来源与覆盖;原生目标的事实写在引擎中:封闭的目标词汇(`triple.cppm`)、每行注入的参数 +(`freestanding/target.cppm`)、以代码判定的 hosted(`-ffreestanding`)、固定的链接方式、以本机为中心的运行时校验、封闭的描述 +符键。提供各层的来源与在来源之间的选择,散在三元组补全、目标表的 pin、平台列与 `toolchain_decision` 的拒绝中,彼此以代理条件 +推断。#911 的 G1–G6、G10,与 `--target x86_64-windows --toolchain llvm` 被拒绝,都由此而来。 + +## 2. 原则 + +**机制在引擎,事实在数据。** 引擎持有: + +- 轴:arch、os、env、对象格式、数据模型、C 环境(hosted 或 freestanding)、执行宿主; +- 层名的闭集(SPEC-002 §1.1); +- 编译器族的驱动知识(SPEC-002 §1.2:编译器不由包提供); +- 协议:合并、来源、校验、打包、运行器、选择规则。 + +哪些系统存在、某一行用何种代码模型、产物由哪个加载器启动、谁提供 C 库,皆为数据。原生约定与 openkal 平台以同一形式表达。 + +## 3. 概念 + +| 概念 | 定义 | +|---|---| +| 请求 | 用户写出的 `--target`、`[build] target` 或 `[target.X]` 的键:`arch-os` 或 `arch-os-env`,是一个模式 | +| 身份 | 产物是什么:由选择得到的完整三元组。决定输出目录、缓存键、`cfg(...)` 与 ABI 标签 | +| 行 | 一个身份的定义(第 4 节) | +| 提供者 | 一个身份的一份完整供给:工具链、各层的来源、链接模型、C 环境、执行环境、默认参数(第 5 节) | +| 选择 | 由请求得到身份与提供者的唯一规则(第 6 节) | + +## 4. 身份与行 + +- **I1. env 即 C 库。** 一个系统可能用到两种以上 C 库时,身份含 env。 +- **I2. 系统的原生身份沿用业界写法。** 业界原生标识无 env 段的系统(macOS、BSD),其原生身份为两段(`aarch64-macos`、 + `x86_64-freebsd`);其余 C 库以 env 区分(`aarch64-macos-musl`)。`-openkal` 为与系统无关的映像身份,两段。 + 其余系统的身份为三段(`x86_64-linux-gnu`、`x86_64-windows-msvc`)。 +- **I3. 行是数据。** 行的字段:`triple`、`arch`、`os`、`env`、`standard`(是否为业界标准身份)、`clang-triple`、 + `object-format`、`data-model`、`exe-suffix`、`loader`、`code-model`、`link-emulation`、`execution`。内置行随 mcpp 发布; + 平台包以 `[[platform.define]]` 定义新行,同一 schema(G1)。解析器接受任何已解析平台定义的 os 与 env;`cfg(os = …)` 按行 + 求值;32 位 x86 的内置行 `i686-none-elf`(空代码模型,`elf_i386`)随内置数据提供(G12)。 +- **I4. 身份与宿主无关。** 同一身份在任何宿主上是同一输出目录、缓存键与 `cfg`。 +- **I5. 身份如实。** 身份为选择的结果,含实际 C 库的 env;产物格式随目标系统:身份为 `-openkal` 时为 ok 映像,否则为 + 该系统的原生格式(ELF、PE、Mach-O),即使各层由 openkal 平台提供。 + +## 5. 提供者 + +提供者以 SPEC-013 §1 的 `[platform]` schema 表达,增加: + +| 键 | 语义 | 对应 | +|---|---|---| +| `serves` | 所服务的身份 | 取代目标表的 pin 与 `hosted.targets` | +| `priority` | 整数,越大越先;同一请求的候选按此排序(Q6) | 第 6 节的顺序 | +| `hosted` | C 环境是否 hosted;sysroot 或 c-abi 提供者亦可声明 `hosted-standard-library` | G3 | +| `[platform.link] driver` | 在每一行生效;`"compiler"` 交由编译器驱动加入启动文件与库 | G4 | +| `execution` | 产物的执行环境(系统、加载器) | G5、G11 | +| 默认参数 | 带来源的数据;更具体的声明方以显式 `replace` 替换,否则追加 | G2 | +| `pairs` | 可与之配对的编译器族(如 mingw 提供者:gcc、llvm) | 第 7 节 | + +**内置提供者。** 原生约定改写为随引擎发布的提供者:glibc payload(`*-linux-gnu`,native)、musl-gcc(`*-linux-musl`, +native)、Apple SDK(`*-macos`,native)、MSVC(`*-windows-msvc`,native)、mingw(`*-windows-gnu`,convention)、Android NDK、 +Emscripten SDK。openkal-platform 的 hosted 部分为提供者,服务 `*-linux-musl`、`*-windows-musl`、 +`*-macos-musl`(待验证,第 11 节)。 + +**存放。** 内置行与提供者为 mcpp 源码树中的数据文件,由构建程序(`build.mcpp`)编入引擎,不提交生成物;运行时不联网、 +不进入依赖解析,「每个 mcpp 固定一份」的可复现性不变。 + +## 6. 选择 + +1. **显式者优先。** `--platform`、`MCPP_TARGET__PLATFORM`、项目与 config 的 `platform`、显式工具链;依赖图提供的层亦属显式 + (层报告为 `graph`)。`--platform none` 只用 `native` 与 `convention` 提供者,不用平台包。 +2. **请求是模式。** 写出 env 的请求只匹配该身份;只写 arch-os 的请求匹配该系统的全部身份。I2 中原生身份为两段的系统,请求 + `aarch64-macos` 同时是该身份的写法与模式:候选的第一个是原生身份。 +3. **顺序是数据。** 每个提供者带一个整数 `priority`,同一请求的候选按它从大到小排序,相同者按行的顺序、再按提供者名。 + 内置数据给出默认值:各系统的标准工具链与 C 库(glibc、musl-gcc、Apple SDK、MSVC、BSD 的系统 libc)较高,跨系统平台 + (openkal-platform)居中,其他约定(mingw)较低。引擎不含类别,也不含任何实现名(SPEC-002 §1.1);所有提供者同一 + 结构,只有数值不同。平台包为自己的提供者声明 `priority`;项目与 config 可按选择器改写: + `[target..priority] = `,来源与其他值一样记录。优先级的调整(如 openkal-glibc 就绪后的 + `*-linux-gnu`)是一次数据改动,附 CHANGELOG。 +4. **取第一个可服务者。** 提供者服务该身份,且其 payload 或包在本宿主可得;工具链族被显式指定时,只取与该族配对的提供者 + (`pairs`)。 +5. **报告。** 状态行沿用现有格式:`Target <写法> → <身份>`,其下为各层的来源;发生回落(第一个候选在本宿主不可服务)时,`Target` + 行附一句原因(`<提供者> is not available here`)。 +6. **皆不可服务时拒绝。** 诊断写出该身份所需、每个候选提供者不可用的原因、可直接执行的命令。 +7. **原生构建**(不写 `--target`)取宿主的 `native` 提供者,结果与现在相同。 + +| 请求 | 候选(依次) | 典型结果 | +|---|---|---| +| `x86_64-linux` | `-linux-gnu`:glibc payload;`-linux-musl`:musl-gcc、openkal-musl | Linux:`-gnu`;无 glibc 与 musl-gcc payload 的宿主:`-musl`(openkal) | +| `x86_64-linux-musl` | musl-gcc、openkal-musl | Linux:musl-gcc;macOS:openkal | +| `x86_64-windows` | `-windows-msvc`:MSVC;`-windows-musl`:openkal;`-windows-gnu`:mingw | 有 MSVC 的 Windows 宿主:`-windows-msvc`;其余:`-windows-musl`(Q4) | +| `x86_64-windows-gnu` | mingw(gcc 或 llvm 配对) | mingw | +| `aarch64-macos` | `aarch64-macos`:Apple SDK;`aarch64-macos-musl`:openkal | macOS:原生;其他宿主:`-musl`(openkal) | +| `-openkal` | openkal-platform | ok 映像 | + +## 7. llvm 与 mingw payload + +本机实测(2026-11-09,clang 23.1.3,`xim-x-mingw-cross-gcc/16.1.0`):C++ 的静态程序与 `import std`(clang 预编译 libstdc++ 的 +`bits/std.cc`,`std::println`)均在 wine 下正常运行;导入表为 `KERNEL32.dll`、`msvcrt.dll`,与 mingw gcc 产物同一 ABI。参数: +`--no-default-config --target=x86_64-w64-mingw32 --sysroot= -stdlib=libstdc++ -rtlib=libgcc -fuse-ld=lld`;静态 +链接另加 `-pthread`(该 mingw 为 posix 线程模型,clang 驱动不自动加入 winpthread);`std::print` 的终端函数在 +`libstdc++exp.a`,与 gcc 一行的处理一致。 + +## 8. 与 #911 其余各项及分发的衔接 + +| 项 | 处置 | +|---|---| +| G6 描述符 | 源码制品一份、与目标无关;预编译 payload 按身份的轴匹配,含 `any` 与回落;`linux`、`macosx`、`windows`、`openkal`、`none` 块作为轴组合的别名读取,已有索引条目不变;`descriptor_keys` 照旧可读,不再必需 | +| G7 能力槽 | SPEC-002 的层机制推广到普通包:`requires` 加按系统的默认提供者;解析顺序为依赖图 > 平台 > 默认值。openkal-run 的清单随之改写 | +| G8 补丁根 | 包根(解析后的清单所在目录)或显式 `patch_root`;`mcpp patch edit/save` | +| G9 按产物的编译变体 | `[targets.X] defines/flags` 作用于该产物的共享源码,按参数键编译变体,只在参数不同处 | +| G10 载体与打包 | 任何已解析平台能构建的行,按平台声明作载体或打包的系统 | +| G11 执行宿主 | 运行器协议:传输、运行、输出流、退出码,由 runner 包实现(VM、ssh、设备);无运行器的异系统产物为「已构建,未运行」 | +| B1 | 生成的清单以 TOML 引号书写含 `.` 的目标名 | +| B2 | 平台规定的产物后缀不被宿主后缀规则覆盖;冲突在规划期报告 | +| B3 | 多个程序可打包时拒绝并说明,提供打包一个包的全部程序的写法 | +| 构建插件(SPEC-007) | SPEC-013 §1.2 的分工不变:行、提供者、层只能声明(解析工具链与依赖图时尚无程序可运行);`build`、`link`、`runner` 可由插件陈述,`replace` 在陈述层,来源记录 | + +## 9. 多视角自审 + +| 视角 | 发现 | 处置 | +|---|---|---| +| 一致性 | 第四版把「类别」同时放在行与提供者上,含义不清 | 行只标 `standard`(是否业界标准身份);排序只看提供者的 `priority`(Q6) | +| 一致性 | 「只有 openkal 是两段」与 macOS 原生无 env 并存 | I2:业界原生标识无 env 的系统,原生身份为两段;其余 C 库以 env 区分 | +| 一致性 | 依赖图提供 musl 而请求为 `x86_64-linux` 时,现有身份为 `-linux-gnu`,报告却写 `c-abi musl`(target_side.cpp 已记录此矛盾) | I5:身份取实际 C 库,为 `-linux-musl`。这是第二处默认行为变化(第 10 节),需确认(Q5) | +| 一致性 | 第四版把 MSVC 列为 Windows 的原生标准却未进入候选 | 候选按类别排序后 MSVC 在首位;无 MSVC 的宿主(Linux、macOS)自然落到 openkal,Windows 宿主的取向由 Q4 决定 | +| 兼容性 | 原生约定由代码移为数据,任何偏差都会改变产物 | 等价性测试:迁移前由现有代码表生成每行的编译与链接参数快照,迁移后由数据生成并逐行比对,二者相同才删除旧代码 | +| 兼容性 | 新清单键(`[[platform.define]]`、`serves`、`priority`、`execution`、`pairs`)在旧 mcpp 上 | 旧 mcpp 对未知键警告(SPEC-013 §1.1);使用新键的平台包声明 `[package] mcpp` 下限 | +| 可复现性 | 同一模糊请求在不同宿主可能解析为不同身份 | 身份与宿主无关(I4);需要确定 ABI 的场合写完整身份;构建报告与机器输出记录「请求 → 身份」;锁按身份记录目标(与 #903 一致) | +| 缓存与 ABI | `-linux-musl` 可由 musl-gcc 或 openkal-musl 提供;二者的预编译产物是否互换 | 缓存键已含工具链与平台的指纹,二者不共享缓存;索引中按身份匹配的预编译 payload 要求 C 层 ABI 兼容,openkal-musl 与 musl-gcc 的 musl 版本差异列为验证项 | +| 跨平台 | 从 macOS 构建 `-linux-gnu` 需要 glibc payload 能装在 macOS 宿主上 | glibc payload 是目标数据,属 G6 的按身份匹配;在其可得之前,该身份在 macOS 宿主上不可服务,请求 `x86_64-linux` 回落到 `-musl` | +| 跨平台 | macOS 无 SDK 的链接(dyld、libSystem 的存根) | 列为验证项(第 11 节);验证前 `aarch64-macos-musl` 在非 macOS 宿主上标为 preview | +| 安全 | 平台包可以定义行与替换默认参数 | 平台包本就能注入参数;`replace` 只作用于带来源的默认参数,`mcpp explain flags` 写出每个参数的来源层 | +| 性能 | 选择不得拉长构建的准备阶段 | 内置行与提供者由构建程序编为引擎中的静态表,运行时不解析任何文件;平台包只在被选中或已解析时读取(与现在相同);候选按优先级依次检查可得性(本地 store 的存在性),取到第一个即停;结果在一次构建内缓存;`--timings` 报告选择所用时间,e2e 断言其上界 | +| 用户体验 | 新增提示可能冗余 | 只在发生回落时附一句原因;其余沿用现有的「写法 → 身份」与层来源行 | +| 诊断 | 拒绝时用户不知道下一步 | 第 6 节第 6 条:所需、各候选的原因、可执行的命令 | + +## 10. 行为变化 + +1. 只写 arch-os 的 Windows 请求:在无 MSVC 的宿主上由 `-windows-gnu`(mingw)改为 `-windows-musl`(openkal)。输出目录与 + `cfg(env)` 随之改变;CHANGELOG 记载,简写解析时提示一次。 +2. 依赖图提供的 C 库与请求的默认 env 不同时,身份取实际 C 库(Q5):例如依赖 openkal-musl 的项目以 `x86_64-linux` 构建,身份由 + `x86_64-linux-gnu` 改为 `x86_64-linux-musl`,输出目录随之改变。 + +其余:现有身份字符串、输出目录与 `cfg` 结果不变;`[target.X]`、`descriptor_keys`、`platform`、`[platform.hosted]` 的写法照旧 +可读,`hosted.targets` 视为 `serves` 的别名。 + +## 11. 验证项 + +- `aarch64-macos-musl` 在非 macOS 宿主上的链接与运行(无 SDK); +- openkal-musl 与 musl-gcc 的 musl 在 C 层 ABI 上的兼容范围(预编译 payload 是否可互用); +- FreeBSD 平台定义的行:静态与动态链接、运行时校验、`cfg(os = "freebsd")`; +- i686 行:空代码模型与 `elf_i386`。 + +## 12. 决定 + +| 编号 | 事项 | 状态 | +|---|---|---| +| Q1 | 所有目标由提供者服务,原生约定改写为内置提供者 | 已确认 | +| Q2 | 新 SPEC-016「目标与提供者」;SPEC-013 §0、§3、§14,SPEC-002 §3.4、§3.5,SPEC-015 §1.2 改为引用之;docs/21、22、35 同步 | 已确认 | +| Q3 | mcpp 一个 release PR 实现第 4–8 节与第 9 节的处置;openkal-image(openkal-run 的能力槽)一个 PR,依赖 mcpp 的发布 | 已确认 | +| Q4 | 宿主有原生提供者时取原生;Windows 宿主有 MSVC 时只写 arch-os 的 Windows 请求取 MSVC,否则取 openkal 平台(优先级次之)。即第 6 节规则的直接结果,无 Windows 专门规则 | 已确认 | +| Q5 | 身份取实际 C 库(第 10 节第 2 条)。理由:预编译 payload 按身份的轴匹配(G6),身份不如实即取错预编译库;同一系统的 C 库增多(openkal-glibc、BSD)后 `cfg(env)` 与 ABI 标签须如实;现有报告的不一致由此消除 | 已确认 | +| Q6 | 提供者在结构上统一,只以整数 `priority` 排序;默认值是数据,平台包、项目与 config 可以改写;引擎不含类别与实现名 | 已确认 | +| Q7 | #906(2026.11.8.1)先合入并发布;本设计为其后的版本,单独一个 PR | 已确认;2211fb5c 合入,release 已触发 | + +## 13. 实现与测试 + +| 部分 | 位置 | +|---|---| +| 行与内置提供者的数据、编入引擎的构建程序 | 新数据目录;`build.mcpp` | +| 三元组:开放的 os 与 env,按行求值 | `modules/toolchain-model/src/triple.cppm` | +| 选择 | `prepare/toolchain.cpp`(`step1_resolve_target_triple_request` 起)、`prepare/platform.cpp`、`platform.cppm` | +| 提供者的编译与链接 | `toolchain/hostflags`、`toolchain/linkmodel`、`build/flags.cppm`、`toolchain/stdmod.cppm`、`freestanding/*` | +| hosted、`driver = "compiler"`、执行环境与运行时校验 | `freestanding/target.cppm`、`build/ninja_backend.cppm`、`build/runtime_validation.cppm` | +| 描述符的轴匹配、补丁根、按产物的变体、载体与打包、运行器协议 | `pm/*`、`pm/patch.cppm`、`build/plan.cppm`、`pack/*`、`run/*` | +| 诊断与报告 | `prepare/toolchain_decision.cpp`、`ui`、机器输出、`mcpp why target` | +| 统一的包引用与 git 的缺省引用 | 依赖的引用解析器(`pm/*`)、平台与运行器的引用读取、命令行与环境变量的字符串形式 | +| B1–B3 | `pack/pack.cppm`、`pack/manifest_emit.cppm`、`pack/distribution.cppm`、`pack/pipeline.cppm` | +| 规范与文档 | SPEC-016(新);SPEC-001、SPEC-002、SPEC-013、SPEC-015 的修订;docs/05、20、21、22、30、35(中英) | +| 测试 | 等价性测试(第 9 节);e2e:只写 arch-os 的交叉构建(payload 可得与不可得)、`-windows-gnu` 配 llvm(含 `import std`,wine 运行)、显式身份不被替换、原生构建不变、显式平台与依赖图提供者、平台定义的 FreeBSD 行、i686 行、hosted 声明、`driver = "compiler"`、异系统产物的运行时校验、能力槽、补丁根、按产物的变体、B1–B3、拒绝时的诊断;引用:命令行的 `git+` 引用(缺省、`tag`、`branch`)、`path` 引用、`[indices]` 中未进索引的描述符、清单引用入锁与命令行引用不入锁、`--locked` 拒绝未锁定的分支、离线时的行为 | + +## 14. 使用者视角的自审 + +| 视角 | 关心 | 发现 | 处置 | +|---|---|---|---| +| 应用开发者 | 写一个 target 就能构建;结果可预期 | 同一模糊请求在不同宿主可能得到不同身份,团队协作时不易察觉 | R1:锁按请求记录解析出的身份;另一宿主解析不同时写出一行说明(请求、锁中的身份、本次的身份),`--locked` 下拒绝 | +| 应用开发者 | 出错时知道怎么办 | 拒绝的诊断已有原则(第 6 节第 6 条),但没有「先问后建」的入口 | R2:`mcpp why target [<请求>]`(与现有的 `why toolchain/runtime/deps` 同族)列出候选、各自的优先级与可得性、选中者与原因;`--format json` | +| 应用开发者 | 不必学新概念 | 「行」「提供者」「优先级」是实现与平台作者的概念 | R3:用户文档只用「请求的 target」「构建的 target」「提供者」三个词;「行」「priority」只出现在 SPEC-016 与平台作者文档 | +| 应用开发者、平台开发者 | 指定用谁 | 「选一个提供者」现在有 `platform`、`toolchain`、`--platform none` 三种写法,语义交叠 | R4:内置提供者有名字(如 `builtin:msvc`、`builtin:mingw`、`builtin:glibc`);`platform` 选择一个提供者,名字或包引用皆可;`--platform none` 表示不用平台包、只用内置提供者,含义不变;`toolchain` 只选编译器族与版本,与提供者按 `pairs` 配对。不新增键或标志 | +| 库开发者 | 条件依赖与预编译 payload 按 target 匹配 | Q5 使依赖图供给 C 库的项目身份改变,以 `cfg(env = "gnu")` 写的条件可能不再成立 | R5:CHANGELOG 与 docs/22 写出迁移:按 C 库写的条件应以实际 C 库为准;`mcpp why target` 显示 `cfg` 求值所用的身份 | +| 平台开发者 | 用最少的键写出一个平台 | `[platform]` 与 `[platform.hosted]` 是两个特例的提供者,第三个提供者没有写法 | R6:一般形式 `[[platform.provider]]`(`serves`、`priority`、`toolchain`、`layers`、`link`、`hosted`、`execution`、`pairs`);`[platform]` 与 `[platform.hosted]` 为其两种简写,照旧可读。`mcpp new --template platform` 生成一个最小平台;`mcpp check platform` 校验 schema、行与提供者的一致、`priority` 冲突,并列出它服务的身份 | +| 构建插件开发者 | 构建程序读到的 target 与引擎一致 | `mcpp::target_os()` 等访问器的文档以封闭集合描述取值;新系统的 os 值须照样流过 | R7:访问器返回身份的轴(字符串,取值由行定义);新增 `mcpp::target_provider()`(`MCPP_TARGET_PROVIDER`);docs/30 的取值表改为「由行定义,内置行为……」 | +| agent | 可脚本化、确定、可解释 | 构建的机器输出只有身份,没有请求与提供者;拒绝没有稳定的代码 | R8:机器输出的 target 记录为 `{request, identity, provider, priority, fallback}`;选择的拒绝有稳定代码(`target-unserved`、`target-ambiguous` 等,进 SPEC-016 的拒绝码表);文档建议自动化写完整身份 | +| mcpp 开发者 | 迁移不引入回归,代码不再散落 | 原生约定由代码移为数据是一次大改 | 第 9 节的等价性快照为合入条件;选择逻辑集中于一个模块,旧的代理条件(目标表 pin、`toolchain_decision` 的拒绝、三元组补全中的推断)随迁移删除,不保留双轨 | + +**简洁性。** 概念:请求、身份、提供者(用户文档三个词);数据:行、提供者(同一 schema);规则:一条(显式者优先,其余按 priority 取第一个可服务者)。新增的用户面:`mcpp why target`、`[target..priority]`、内置提供者的名字;其余为已有写法的推广。 + +## 15. 统一的包引用 + +凡是「指名一个要取得的东西」的位置,使用依赖的同一套引用文法与解析机制(SPEC-001): + +| 位置 | 现在 | 统一后 | +|---|---|---| +| `[dependencies]` | 版本、`path`、`git`(SPEC-001) | 不变,为文法的来源 | +| `platform`(含 `--platform`、环境变量) | 包引用(字符串或表,含 `path`、`git`);命令行 `path:` | 同一文法;增加 `builtin:`(R4)与命令行的 git 形式 | +| `[platform.layers]`、`tools`、`carrier` | 包引用 | 不变 | +| 运行器包(G11) | 无 | 同一文法 | +| `toolchain`(含 `--toolchain`) | `<族>@<版本>`、`xim:<包>@<版本>`、`path:` | 不变(编译器不由包提供,SPEC-002 §1.2);`<族>@<版本>` 为 `xim:` 的简写 | + +**文法。** TOML 中为字符串或表(`name`、`version`、`path`、`git` 与 `tag`/`rev`/`branch`、`features`),即现有的包引用;命令行与环境变量 +为字符串:`[.]@<约束>`、`path:`(或以 `/`、`./`、`../`、`~`、盘符开头的路径)、 +`git+[#tag=|rev=|branch=]`、`builtin:`。`git+` 之后接 `https://`、`ssh://` 或 scp 形式 +(`git@host:owner/repo`)的地址。 + +**缺省的引用。** git 引用不写 `tag`、`rev`、`branch` 时,取远端的默认分支:由 `git ls-remote --symref HEAD` 得到其名字(`main`、 +`master` 或其他),不在引擎中写死分支名;远端不通告默认分支时,依次尝试 `main` 与 `master`,并在报告中写出所用的分支。缺省引用 +按 `branch` 处理(锁记录解析得到的提交)。`[dependencies]` 的 git 依赖同样适用:SPEC-001 由「`tag`、`rev`、`branch` 恰好一个」改为 +「至多一个,省略即默认分支」;原先被拒绝的写法由此成为合法,已有清单不受影响。 + +**未分发的仓库。** 符合 mcpp-index 分发模型、含 `mcpp.toml` 的仓库或目录,以 `path`、`git` 引用,身份取其清单(SPEC-001 §5.4), +与索引中的包同样解析、获取与锁定。只有 xpkg 描述符(`.lua`)而未进索引的,以项目的 `[indices]`(`path` 或 `git`)声明为一个索引, +其中的包以普通的 `.@<版本>` 引用。不新增第三种来源。 + +**锁。** 清单中写出的引用记入 `mcpp.lock`(git 记录解析得到的提交);只在命令行或环境变量中出现的引用是一次构建的选择,不改写锁, +记入构建报告与机器输出(R8)。 + +**跨平台。** 路径按 SPEC-013 §3.2 的规则识别(含 Windows 盘符);git 取得与 `[dependencies]` 的 git 依赖同一实现与缓存。 + +**稳定性与安全。** `branch` 引用不可复现:报告写出解析得到的提交,`--locked` 下拒绝未锁定的 `branch`;引用带来的构建程序(平台插件) +与 git 依赖受同样的信任规则(`untrusted_because`)。 + +**性能。** git 引用按(仓库、提交)缓存于 store,浅克隆;`path` 引用不复制;同一来源在一次构建中只解析一次。 + +## 16. 综合自审(全文) + +| 视角 | 检查 | 结论或修订 | +|---|---|---| +| 一致性 | 引用文法是否处处同一 | 依赖、平台、层、工具、载体、运行器包同一文法与解析器;`builtin:` 只用于提供者;`toolchain` 保留自己的写法,理由在 SPEC-002 §1.2 | +| 一致性 | 第 5 节的提供者 schema 与 R6 的 `[[platform.provider]]` | 第 5 节的键即 `[[platform.provider]]` 的键;`[platform]`、`[platform.hosted]` 为其两种简写(R6) | +| 一致性 | git 的缺省引用与依赖 | 两处同一规则(第 15 节),SPEC-001 随之修订 | +| 可复现性 | 缺省引用与 `branch` 会移动 | 清单中的引用由锁固定到提交;命令行的引用每次按远端解析,报告写出提交;`--locked` 下拒绝未锁定者 | +| 离线 | 无网络时 | 锁中已有提交且 store 中已取得:直接使用;否则以「需要网络解析 」拒绝,并给出 `--offline` 的含义(只用已取得的) | +| 安全 | 命令行的 git 引用带来构建程序 | 运行前写出来源与提交;信任规则同 git 依赖(`untrusted_because`) | +| 跨平台 | 路径、URL 形式 | 路径识别含 Windows 盘符(SPEC-013 §3.2);git 地址的三种形式在各系统一致 | +| 性能 | 选择与引用不得拉长准备阶段 | 内置数据为静态表;可得性检查只读本地 store;git 按(仓库、提交)缓存、浅克隆,缺省引用每次一次 `ls-remote`;一次构建内每个来源只解析一次;`--timings` 报告选择与引用解析的时间 | +| 用户体验 | 新增的写法是否必要 | 新增的用户面:`mcpp why target`、`[target..priority]`、`builtin:`、命令行的 `git+` 形式;其余为已有写法的推广 | +| agent | 可脚本化 | 机器输出的 target 记录(R8)与引用记录(来源、解析得到的提交、是否来自命令行);拒绝码稳定 | +| 可维护性 | 实现集中 | 选择一个模块、引用一个解析器(依赖的那一个);旧的代理条件随迁移删除;等价性快照为合入条件 | +| 规范 | 受影响的规范 | 新 SPEC-016;修订 SPEC-001(git 的缺省引用;引用文法的适用范围)、SPEC-002(§3.4、§3.5、hosted 声明、能力槽)、SPEC-013(§0、§1 的 provider 一般形式、§3、§14)、SPEC-015(§1.2);docs/05、20、21、22、30、35 | + + +## 17. 测试与构建基础设施(2026-11-09 补充) + +同一 mcpp 发布包含以下几项。它们与目标模型同属引擎的契约,不另开 PR。 + +### 17.1 单元测试迁移到 mtest + +mcpp 的单元测试(`tests/unit/` 166 个文件,以及各成员的 `modules//tests/`)由 GoogleTest 改写为 mtest 的用例对象: +- 每个 `TEST` 改写为一个 `UTest` 对象,标识为 `Suite/Case`,文件中不写 `main`; +- 夹具改写为派生自它的用例; +- 只在失败时才有效的消息改为惰性求值; +- 启动自身副本的测试以用例标识选择副本,并清除副本中的协议变量; +- 标准流捕获使用 mcpplibs.os 0.4.1 的 `stdio::capture`。 + +已实现,全部用例通过(根 166、成员 10)。 + +### 17.2 构建缓存的分级 + +实测(mcpp 仓库,一个配置): +- 配置目录共 5.5G,其中 `bin/` 占 5.1G:166 个测试程序,每个约 31MB,因为每个都链接了全部 483 个目标文件; +- `obj` 为 164M,`gcm` 为 154M; +- `mcpp test -p <成员>` 另开一个配置目录,重编译成员; +- 全局缓存中的对象以复制方式进入配置目录。 + +分级与规则: + +| 层 | 位置 | 键 | 内容 | +|---|---|---|---| +| global | `~/.mcpp/build-cache/v1` | 包身份与包配置指纹 | 索引包、std 模块、宿主工具;以硬链接(或 reflink)物化,不复制 | +| workspace | `target/.cache/pkg/<包>@<指纹>` | 成员自身的配置指纹 | 工作区成员(path 包)的对象、BMI 与静态库形态;根构建、`-p`、build 与 test 共用 | +| package | 同上一条目 | — | 一个包的编译结果 | +| target | `target//<配置指纹>/` | 消费者配置 | 仅链接产物 | + +测试程序链接各包的静态库形态,加 `--gc-sections`,只拉入被引用的部分。 +- 实测 test_diag:由 32.1MB 降至 14.7MB,代码段由 17.5MB 降至 2.7MB。 +- 支持时使用 `-gsplit-dwarf`,调试信息按对象只存一份。 +- 依靠静态构造自注册、无符号被引用的对象,以 whole-archive 链入。 +- `[test] link = "objects"` 保留逐对象链接作为退路。 + +配置目录与 workspace 缓存按指纹失效和 LRU 回收,由 `mcpp cache gc` 与 `mcpp clean --stale` 执行。 + +### 17.3 测试程序的组成与用例级调度 + +- 一个测试目录中只声明对象、编译与链接配置相同的文件,链接为一个测试程序(每包每目录一个)。缺省即此。 +- 自带 `main` 或有特殊参数的文件单独成程序;`[test] program = "file"` 恢复逐文件成程序。 +- 调度粒度为用例:引擎以协议列出用例,以同一程序的 N 个进程执行,每个进程由 `MTEST_SELECT` 选取用例。 + - 顺序按历史耗时,长者在前; + - 遵守 `machine`、`serial:` 与内存容量(17.5)。 +- `mcpp test` 将每次运行写入项目的 mtest store(`[test] store`,缺省 `.mtest`),带每个程序的构建区间。由此 `mtest report|analyze|graph|trend` 读得到 `mcpp test` 的运行。 + +### 17.4 运行是一张有向无环图;报告 + +- 一次测试运行是一张 DAG: + - 节点为编译、链接、程序启动、用例、run 作用域夹具的建立与回收; + - 边为构建依赖、"程序构建 → 其用例"、同一串行资源的先后持有者、worker 槽位的释放与占用。 +- 关键路径是该 DAG 的最长加权路径(权为实测墙钟)。`mtest analyze path` 由"最长的程序"改为这一定义。 +- `mtest report --format md`: + - 章节可选:summary、kinds、slowest、failures、metrics、flaky、trend、graph、path、gantt、resources; + - 范围可选:run、set、program、kind; + - 选择方式为参数或 `mtest.toml` 的 `[report]`; + - 图为 mermaid:DAG 中标出关键路径,并给出按 worker 与资源分行的 gantt。 +- `mtest run --report md:FILE` 在运行结束时写出报告;`mtest ci annotate` 将其写入 job summary。 +- 各 job 上传本地报告与 store;汇总 job 以 `mtest ci collect` 合并后,生成整体报告。 +- `mtest ci` 可读取 GitHub 的 job 时间线,给出流水线层的 gantt 与关键路径。 + +### 17.5 资源监控 + +| 级别 | 内容 | 开销 | +|---|---|---| +| `off` | 无 | — | +| `basic`(缺省) | 进程级:墙钟、user/sys CPU、峰值内存;用例级:墙钟、CPU | 进程与用例边界各一次系统调用 | +| `detailed` | 另加用例级内存(峰值、增量)、IO 字节、分配次数与字节(可选模块) | 小 | +| `trace` | 另加 CPU% 与内存的时间序列,采样间隔可设 | 随采样间隔 | + +- 写法有三种:用例属性 `monitor{.memory = true, .sample = 10ms}`、`mtest.toml` 的 `[monitor]`、`--monitor <级别>`。代码优先。 +- 数据以标准指标名进入协议 P6,每项带精度(exact、process、approx):`cpu.user`、`cpu.sys`、`mem.peak`、`mem.delta`、`io.read`、`io.write`、`net.sent`、`net.recv`、`alloc.count`、`alloc.bytes`、`proc.spawned`。 +- 网络:经 mcpplibs.os 的套接字为 exact,其余为 approx。 +- 现有的上下界(`at_most{"mem.peak", 64_MiB}`)与趋势分析直接适用。调度以历史峰值内存作为容量约束。 +- 进程资源用量由 mcpplibs.os 提供:子进程与自身的 CPU、峰值内存、IO 计数。 + +### 17.6 可复用测试组件 + +- 组件是一个包,声明 `provides = ["mtest:component="]`,向框架注册: + - 夹具及其作用域(case、suite、program、run); + - 它管理的资源与容量; + - 新的用例类型与属性; + - 它贡献的指标与报告章节。 +- 注册方式与用例相同(静态初始化)。`list --static` 与协议的 `components` 事件列出组件。 +- run 作用域的夹具由引擎在首个使用者之前启动、在最后一个使用者之后回收,端点以 `MTEST_FIXTURE_` 交给用例;建立与回收是 DAG 的节点。 +- 组件一律经进程与协议交互,与 adapters 相同,与语言无关。 +- mcpp 的组件:沙箱 home、假 xpkg 索引、git 服务、工程树与 golden、标准流捕获、假工具链载荷。mcppls 的组件:一致性夹具。 + +### 17.7 决定 + +| 编号 | 事项 | 状态 | +|---|---|---| +| T1 | 17.2 缓存分级与测试的静态库链接进入本 PR | 已确认 | +| T2 | 17.3 按目录组成程序(缺省)与用例级调度;mtest 提供多种方式供开发者选择 | 已确认 | +| T3 | mcppls #79 的 CI 编排(已发布的 mcpp、按用例分片、store 与报告) | 已确认 | +| T4 | 17.4 的 DAG、关键路径、Markdown 报告与汇总在 mtest 0.5.0;`mcpp test` 写 mtest store | 已确认 | +| T5 | 17.5 资源监控:缺省 `basic`;网络的精度按上文 | 建议,待确认 | +| T6 | 17.6 组件机制在 mtest 0.5.0;mcpp 自身测试的共用部分改为组件 | 建议,待确认 | + +## 18. 任务与依赖 + +| 编号 | 仓库 | 任务 | 依赖 | +|---|---|---|---| +| A1 | mcpp | 行与内置提供者为数据(build.mcpp 编入静态表)、等价性快照 | — (已完成) | +| A2 | mcpp | 选择:行的登记、开放的 os 与 env、`select`、prepare 接入(pin、平台、可服务判断、报告) | A1(进行中) | +| A3 | mcpp | 身份取实际 C 库(重新准备)、显式身份与图冲突的拒绝 | A2 | +| A4 | mcpp | `[[platform.define]]`、`[[platform.provider]]` 的读取与登记;`[platform]`、`[platform.hosted]` 为简写;`mcpp check platform`、`mcpp new --template platform` | A2 | +| A5 | mcpp | llvm 配 mingw 载荷 | A2 | +| A6 | mcpp | #911 G2–G11、B1–B3 | A4 | +| A7 | mcpp | 统一的包引用(`git+`、缺省分支、`builtin:`);SPEC-001 修订 | — | +| A8 | mcpp | 锁记录"请求 → 身份"、`--locked`;`mcpp why target`;机器输出;`mcpp::target_provider()`;拒绝码 | A2 | +| B1 | mcpplibs/os | 0.4.1:`stdio::capture`、进程资源用量 | — (#20 进行中) | +| B2 | mcpp | 单元测试迁移到 mtest | B1 (已完成,待 os 0.4.1 发布后改锁) | +| C1 | mtest | 0.5.0:DAG、关键路径、Markdown 报告、汇总、监控、组件、多进程用例调度所需的协议增补 | B1 | +| C2 | mcpp | 缓存分级、静态库链接、按目录组成程序、用例级调度、写 mtest store、资源采集 | C1 | +| C3 | mcpp | e2e 由 mtest 引擎作为唯一入口;共用部分为组件 | C1 | +| D1 | openkal-platform | hosted 部分的服务改写为 musl 身份 | A4 | +| D2 | openkal-image | openkal-run 的能力槽(G7) | A6、mcpp 发布 | +| E1 | mcppls | #79:已发布的 mcpp 2026.11.8.1、CI 编排、mtest 0.5.0 报告 | C1 | +| F1 | 全部 | 规范:SPEC-016(新),SPEC-001、002、013、015 的修订;docs 05、08、20、21、22、30、35、90(中英) | A2–C3 | +| G1 | 全部 | CI 全绿、生态级自审、合入与发布、xim-pkgindex、clean-room 验证、关闭 issue | 以上全部 | + +可以并行的部分:B1、C1 的 CI 运行期间推进 A2–A8;mcpp 的 CI 运行期间推进 D1、E1。发布顺序为 os → mtest → mcpp → openkal-platform 与 openkal-image → mcppls。 From f47ed714f259b218dac9d2bec37b9f0bfa7ce8cf Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 05:55:16 +0900 Subject: [PATCH 06/45] docs: T5 and T6 confirmed Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .agents/docs/2026-11-09-target-model-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/docs/2026-11-09-target-model-design.md b/.agents/docs/2026-11-09-target-model-design.md index 7a5a893bd..aa1330792 100644 --- a/.agents/docs/2026-11-09-target-model-design.md +++ b/.agents/docs/2026-11-09-target-model-design.md @@ -6,7 +6,7 @@ status: accepted # 目标与提供者:统一的目标模型(结合 mcpp#911) **日期:** 2026-11-09 -**状态:** 定稿(2026-11-09):Q1–Q7、R1–R8、第 15 节与第 17 节 T1–T4 已确认;T5、T6 待确认;第 16 节为综合自审,第 18 节为任务与依赖 +**状态:** 定稿(2026-11-09):Q1–Q7、R1–R8、第 15 节与第 17 节 T1–T6 已确认(2026-11-09);第 16 节为综合自审,第 18 节为任务与依赖 **范围:** mcpp(目标的身份、行、提供者与选择;新 SPEC-016 及 SPEC-002、SPEC-013、SPEC-015 的修订);mcpp-index 描述符的 匹配;openkal-image(openkal-run 的能力槽) **输入:** 2026-11-09 关于交叉构建的讨论;mcpp#911(移植 ChenPi11/cmd 到 ok 映像、FreeBSD 与 i386 时测得的 G1–G12 与 B1–B3) @@ -360,8 +360,8 @@ mcpp 的单元测试(`tests/unit/` 166 个文件,以及各成员的 `modules | T2 | 17.3 按目录组成程序(缺省)与用例级调度;mtest 提供多种方式供开发者选择 | 已确认 | | T3 | mcppls #79 的 CI 编排(已发布的 mcpp、按用例分片、store 与报告) | 已确认 | | T4 | 17.4 的 DAG、关键路径、Markdown 报告与汇总在 mtest 0.5.0;`mcpp test` 写 mtest store | 已确认 | -| T5 | 17.5 资源监控:缺省 `basic`;网络的精度按上文 | 建议,待确认 | -| T6 | 17.6 组件机制在 mtest 0.5.0;mcpp 自身测试的共用部分改为组件 | 建议,待确认 | +| T5 | 17.5 资源监控:缺省 `basic`;网络经 mcpplibs.os 为 exact,其余为 approx | 已确认 | +| T6 | 17.6 组件机制在 mtest 0.5.0;mcpp 自身测试的共用部分在本 PR 改为组件 | 已确认 | ## 18. 任务与依赖 From a2601da5b1964a5542f64671a9d0c3e03fb007ac Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 05:58:49 +0900 Subject: [PATCH 07/45] target: prepare selects the identity and its provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request is a pattern: the selection gives the row and the provider whose toolchain, C library, linkage and platform the build uses; a declared family, builtin: and a chosen platform package restrict it, and [target..priority] reorders it. The report reads request → identity (clang triple), with the reason when the first candidate was not available here. A hosted platform entry serves its system whatever C library names the env. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- modules/manifest/src/platform.cppm | 11 ++- modules/manifest/src/platform_toml.cppm | 16 ++++ modules/manifest/src/targetside_model.cppm | 20 +++-- modules/manifest/src/toml.cppm | 10 ++- .../toolchain-model/src/target_select.cppm | 3 + src/build/prepare/platform.cpp | 37 +++++++++- src/build/prepare/state.cppm | 32 ++++++++ src/build/prepare/target_choice.cpp | 50 +++++++++++++ src/build/prepare/target_side.cpp | 8 +- src/build/prepare/toolchain.cpp | 74 ++++++++++++++++++- src/platform/platform.cppm | 20 ++++- 11 files changed, 267 insertions(+), 14 deletions(-) create mode 100644 src/build/prepare/target_choice.cpp diff --git a/modules/manifest/src/platform.cppm b/modules/manifest/src/platform.cppm index 9b5680418..3fa0d7a75 100644 --- a/modules/manifest/src/platform.cppm +++ b/modules/manifest/src/platform.cppm @@ -278,6 +278,11 @@ struct Override { Origin platformOrigin; bool platformSet = false; bool platformNone = false; // `platform = "none"` + std::string platformBuiltin; // `platform = "builtin:"`: a built-in provider + // `[target..priority] = ` (SPEC-016 §4.3): the + // priority of a provider for the rows the selector matches. + std::map priority; + Origin priorityOrigin; Statement fields; }; @@ -307,7 +312,7 @@ inline constexpr std::string_view kHostedKeys[] = { "targets", "toolchain", "lay // override a platform. `runner`, `runners`, `toolchain` and `sysroot` are the // table's existing keys and keep their parsers. inline constexpr std::string_view kOverrideKeys[] = { - "platform", "layers", "tools", "carrier", "link", + "platform", "priority", "layers", "tools", "carrier", "link", }; // The carrier entry of `s` that serves `pair` (`-`): the first whose @@ -366,10 +371,12 @@ inline std::string identity(const Override& o) { std::string s = o.selector; auto add = [&](const Origin& g) { s += std::format("|{}:{}:{}", g.file, g.line, g.key); }; if (o.platformSet) { - s += o.platformNone ? std::string("|none") : "|" + display(o.platform); + s += !o.platformBuiltin.empty() ? "|builtin:" + o.platformBuiltin + : o.platformNone ? std::string("|none") : "|" + display(o.platform); add(o.platformOrigin); } auto const& f = o.fields; + for (auto const& [k, n] : o.priority) s += std::format("|priority:{}={}", k, n); for (auto const& [k, e] : f.layers) add(e.origin); for (auto const& [k, e] : f.tools) add(e.origin); for (auto const& [k, w] : f.runners) add(w.origin); diff --git a/modules/manifest/src/platform_toml.cppm b/modules/manifest/src/platform_toml.cppm index 0e8c2727f..df4e72383 100644 --- a/modules/manifest/src/platform_toml.cppm +++ b/modules/manifest/src/platform_toml.cppm @@ -686,6 +686,12 @@ Reader::override_(const toml::Table& t, std::string_view selector) const { if (k == "platform") { if (v.is_string() && v.as_string() == "none") { o.platformNone = true; + } else if (v.is_string() && v.as_string().starts_with("builtin:")) { + // A built-in provider by name (SPEC-016 §4): no platform package. + o.platformNone = true; + o.platformBuiltin = v.as_string().substr(8); + if (o.platformBuiltin.empty()) + return std::unexpected(std::format("{}: `builtin:` names no provider", key)); } else { auto r = ref(v, key, false, false); if (!r) return std::unexpected(r.error()); @@ -693,6 +699,16 @@ Reader::override_(const toml::Table& t, std::string_view selector) const { } o.platformOrigin = at(v, key); o.platformSet = true; + } else if (k == "priority" && !selector.empty()) { + if (!v.is_table()) + return std::unexpected(std::format( + "{} must be a table of = , for example mingw = 200", key)); + for (auto const& [pk, pv] : v.as_table()) { + if (!pv.is_int()) + return std::unexpected(std::format("{}.{} must be an integer", key, pk)); + o.priority[pk] = static_cast(pv.as_int()); + } + o.priorityOrigin = at(v, key); } else if (selector.empty()) { // `[host]` selects the host graph's platform and nothing else. warn(std::format( diff --git a/modules/manifest/src/targetside_model.cppm b/modules/manifest/src/targetside_model.cppm index 304b59366..f2fbe7d3d 100644 --- a/modules/manifest/src/targetside_model.cppm +++ b/modules/manifest/src/targetside_model.cppm @@ -1098,13 +1098,23 @@ inline std::string format_layers(const TargetSide& ts, bool verbose) { } inline std::string format_report(const TargetSide& ts, std::string_view targetName, - bool verbose = false) { + bool verbose = false, std::string_view request = {}, + std::string_view fallback = {}) { // The head carries no verb of its own: the caller supplies one through the // status line's own padding, and the layer lines below are indented to sit - // under it. - std::string head = (ts.llvmTriple.empty() || ts.llvmTriple == targetName) - ? std::format("{}", targetName) - : std::format("{} → {}", targetName, ts.llvmTriple); + // under it. A request that is a pattern reads ` → ` + // (SPEC-016 §4.5), with the compiler's triple beside it; a selection that + // passed over its first candidate says why, on the same line. + std::string head; + if (!request.empty() && request != targetName) { + head = std::format("{} → {}", request, targetName); + if (!ts.llvmTriple.empty() && ts.llvmTriple != targetName) head += std::format(" ({})", ts.llvmTriple); + } else { + head = (ts.llvmTriple.empty() || ts.llvmTriple == targetName) + ? std::format("{}", targetName) + : std::format("{} → {}", targetName, ts.llvmTriple); + } + if (!fallback.empty()) head += std::format("; {}", fallback); // WHEN THE SEGMENT IS NOT A C LIBRARY, SAY WHAT IT IS — HERE, WHERE THE // READER IS LOOKING AT IT. diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index bb4ff76ac..d9080fa35 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -4216,6 +4216,14 @@ std::expected parse_document(const t::Document& documen "[target.{}].link must be a table, e.g. [target.{}.link] start = \"\"", triple, triple))); platformKeys = true; } + // The priority of a provider for the rows this selector matches + // (SPEC-016 §4.3): data the selection orders candidates by. + if (auto it = body.find("priority"); it != body.end()) { + if (!it->second.is_table()) + return std::unexpected(error(origin, std::format( + "[target.{}].priority must be a table, e.g. [target.{}.priority] mingw = 200", triple, triple))); + platformKeys = true; + } if (platformKeys || body.contains("runner") || body.contains("runners")) { mcpp::manifest::platform::Reader rd{origin, mcpp::manifest::platform::Tier::Manifest, &m.schemaWarnings}; @@ -4268,7 +4276,7 @@ std::expected parse_document(const t::Document& documen static constexpr std::string_view kKnownTargetTables[] = { "abi", "build", "build-dependencies", "carrier", "dependencies", "dev-dependencies", "feature-deps", "feature-requires-abi", - "feature-xlings", "layers", "link", "requires_abi", "runners", "runtime", + "feature-xlings", "layers", "link", "priority", "requires_abi", "runners", "runtime", "targets", "tools", "xlings", }; for (auto& [key, value] : body) { diff --git a/modules/toolchain-model/src/target_select.cppm b/modules/toolchain-model/src/target_select.cppm index f2168dcda..564a91831 100644 --- a/modules/toolchain-model/src/target_select.cppm +++ b/modules/toolchain-model/src/target_select.cppm @@ -43,6 +43,8 @@ struct Context { std::string family; // A provider chosen by name (`builtin:`): only it serves. std::string provider; + // `--platform none`: no provider a platform package implements serves. + bool platformPackages = true; }; struct Selection { @@ -102,6 +104,7 @@ Selection select(std::string_view request, std::string_view arch, std::string_vi for (auto const* p : all) { if (!serves(*p, row.triple)) continue; if (!ctx.provider.empty() && p->name != ctx.provider) continue; + if (!ctx.platformPackages && !p->platform.empty()) continue; served = true; Candidate c{ &row, p, p->priority, true, {} }; if (ctx.priority) diff --git a/src/build/prepare/platform.cpp b/src/build/prepare/platform.cpp index 1d92e7fd0..d6ed43549 100644 --- a/src/build/prepare/platform.cpp +++ b/src/build/prepare/platform.cpp @@ -30,6 +30,9 @@ import mcpp.project; import mcpp.targetside; import mcpp.toolchain.triple; import mcpp.toolchain.platform; +import mcpp.toolchain.target_schema; +import mcpp.toolchain.target_rows; +import mcpp.toolchain.target_select; import mcpp.ui; import mcpp.version_req; import mcpp.pm.resolver; @@ -143,16 +146,44 @@ platform_selection_for(const mcpp::toolchain::triple::Triple& t, const BuildOver if (auto c = mcpp::config::load_or_init(/*quiet=*/true, {}, {}, mcpp::config::Initialization::ReadOnly)) config = c->platformOverrides; + // The built-in default is the platform of the provider the selection takes + // for this identity on this host (SPEC-016 §4), not of the row's first. std::string column; - if (auto* row = mcpp::toolchain::triple::find_known_target(t)) column = std::string(row->platform); + { + auto ctx = target_context(project, config, {}, {}, [] { return false; }); + auto s = mcpp::toolchain::target::select(t.str(), t.arch, t.os, t.env, true, ctx); + if (s.provider) column = std::string(s.provider->platform); + } mcpp::platform::ChoiceInputs in{ flag, env, envKey, project, config, column }; if (overrides.platform_choice.empty() && !stated_on_command_line("MCPP_PLATFORM")) in.flagKey = "MCPP_PLATFORM"; auto sel = mcpp::platform::select_target(in, matcher(cfgpred::context_for(t.str()))); if (!sel) return std::unexpected(sel.error()); + if (!sel->builtin.empty()) return "builtin:" + sel->builtin; return sel->none ? std::string{} : mp::display(sel->ref); } +std::string named_provider(const PrepareState& state, const mcpp::toolchain::triple::Triple& t, + std::span config) { + const auto flag = !state.overrides.platform_choice.empty() + ? state.overrides.platform_choice + : mcpp::invocation::get("MCPP_PLATFORM").value_or(""); + const auto envKey = target_env_name(t, "PLATFORM"); + const auto env = mcpp::invocation::get(envKey).value_or(""); + mcpp::platform::ChoiceInputs in{ flag, env, envKey, project_tables(*state.m), config, {} }; + auto sel = mcpp::platform::select_target(in, matcher(cfgpred::context_for(t.str()))); + if (!sel || sel->origin.tier == mp::Tier::BuiltIn) return {}; + if (!sel->builtin.empty()) return sel->builtin; + if (sel->none) return "none"; + const auto chosen = known_short_name(sel->ref); + for (auto const* p : mcpp::toolchain::target::providers()) { + if (p->platform.empty()) continue; + auto ref = mp::parse_ref_text(p->platform); + if (ref && ref->spec.shortName == chosen) return std::string(p->name); + } + return {}; +} + cfgpred::Ctx platform_cfg_context(const PrepareState& state) { return cfgpred::context_for(target_of(state).str(), state.platformPackageShortName); } @@ -185,8 +216,10 @@ std::expected step1_select_platform(PrepareState& state) { auto project = project_tables(*state.m); std::vector config; if (auto cfg = state.get_cfg(false); cfg) config = (*cfg)->platformOverrides; + // The built-in default: the platform of the provider the selection took. std::string column; - if (auto* row = mcpp::toolchain::triple::find_known_target(t)) column = std::string(row->platform); + if (state.selectedTarget) column = std::string(state.selectedTarget->platform); + else if (auto* row = mcpp::toolchain::triple::find_known_target(t)) column = std::string(row->platform); mcpp::platform::ChoiceInputs in{ flag, env, envKey, project, config, column }; if (state.overrides.platform_choice.empty() && !stated_on_command_line("MCPP_PLATFORM")) in.flagKey = "MCPP_PLATFORM"; diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index b795b50a5..f08b0cc99 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -42,6 +42,7 @@ import mcpp.toolchain.stdmod; import mcpp.toolchain.post_install; import mcpp.toolchain.abi; import mcpp.toolchain.triple; +import mcpp.toolchain.target_select; import mcpp.toolchain.base; import mcpp.build.linkage_form; // #519 — which form each dependency takes import mcpp.build.plan; @@ -294,6 +295,26 @@ struct BuildProgramBinary { // Not copied: copying this by value would copy every dependency-graph and // plan structure prepare_build ever builds, silently, at whichever call // happened to pass it by value instead of by reference. +// WHICH ROW AND WHICH PROVIDER A REQUEST WAS GIVEN (SPEC-016 §4), as the report, +// the machine output, the lock and `mcpp why target` state it. +struct TargetChoice { + std::string request; // as written: `--target`, `[build] target`, the default + std::string identity; // the row taken + std::string provider; // its provider; empty when no provider serves it + int priority = 0; + bool fallback = false; // the first candidate was not available here + std::string why; // why the first candidate was not taken + std::vector candidates; +}; + +// The selection context of a configuration (prepare/target_choice.cpp): this host, +// MSVC's presence, a declared family, a provider chosen by name, and the +// priorities `[target..priority]` of the project and the machine set. +mcpp::toolchain::target::Context target_context(std::span project, + std::span config, + std::string family, std::string provider, + std::function msvcInstalled); + struct PrepareState { PrepareState(bool print_fingerprint_, bool includeDevDeps_, std::vector extraTargets_, @@ -334,6 +355,11 @@ struct PrepareState { bool targetPinIsCapability = false; std::string targetRowPin; std::string targetRowName; + TargetChoice targetChoice; + // The row of the identity, with the facts of the provider that serves it + // (its toolchain, C library, linkage, platform) in place of the default + // provider's. + std::optional selectedTarget; bool tcSpecIsMsvc = false; std::optional root; std::optional effective; @@ -1205,6 +1231,12 @@ std::expected step9_member_build_programs(PrepareState& state // The platform through the phases (prepare/platform.cpp). mcpp::build::cfgpred::Ctx platform_cfg_context(const PrepareState& state); std::expected step1_select_platform(PrepareState& state); +// The provider a configuration chose by name, from the explicit platform choices +// (SPEC-013 §3.1 steps 1-4): `builtin:`, or the provider a chosen platform +// package implements; "none" for `--platform none`; empty when nothing chose. +std::string named_provider(const PrepareState& state, const mcpp::toolchain::triple::Triple& t, + std::span config); + void step4b_seed_platform(PrepareState& state); std::expected step4b_apply_platform(PrepareState& state); std::expected step4b_check_platform_versions(PrepareState& state); diff --git a/src/build/prepare/target_choice.cpp b/src/build/prepare/target_choice.cpp new file mode 100644 index 000000000..96e1822f1 --- /dev/null +++ b/src/build/prepare/target_choice.cpp @@ -0,0 +1,50 @@ +// The selection context of a configuration (SPEC-016 §4): the host the +// candidates' availability is asked of, the probes, the declared family and the +// provider chosen by name, and the priorities the project and the machine set. + +module mcpp.build.prepare; +import :state; + +import std; +import mcpp.build.prepare_inputs; +import mcpp.manifest; +import mcpp.platform; +import mcpp.toolchain.platform; +import mcpp.toolchain.target_schema; +import mcpp.toolchain.target_select; + +namespace mcpp::build { + +namespace mp = mcpp::manifest::platform; + +mcpp::toolchain::target::Context target_context(std::span project, + std::span config, + std::string family, std::string provider, + std::function msvcInstalled) { + mcpp::toolchain::target::Context ctx; + ctx.host = mcpp::toolchain::target::host_pair(mcpp::toolchain::platform::name, + mcpp::toolchain::platform::host_arch); + ctx.probe = [msvc = std::move(msvcInstalled)](std::string_view p) { + return p == "msvc" && msvc && msvc(); + }; + ctx.family = std::move(family); + ctx.provider = std::move(provider); + // The project's tables over the machine's; within one, the most specific + // matching table that sets the provider's priority. + std::vector p(project.begin(), project.end()), c(config.begin(), config.end()); + ctx.priority = [p = std::move(p), c = std::move(c)](const mcpp::toolchain::target::Row& row, + std::string_view name) -> std::optional { + const auto cfg = cfgpred::context_for(row.triple); + auto matches = [&](std::string_view sel) { return cfgpred::matches(std::string(sel), cfg); }; + for (auto const* tables : { &p, &c }) { + std::optional found; + for (auto const* o : mcpp::platform::matching(*tables, matches)) + if (auto it = o->priority.find(std::string(name)); it != o->priority.end()) found = it->second; + if (found) return found; + } + return std::nullopt; + }; + return ctx; +} + +} // namespace mcpp::build diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp index 995bcaef7..f8e4ae12d 100644 --- a/src/build/prepare/target_side.cpp +++ b/src/build/prepare/target_side.cpp @@ -1312,8 +1312,14 @@ step9_same_os_check_and_report(PrepareState& state) { &state.resolvedTargetSide.cAbi, &state.resolvedTargetSide.cxx }) if (l->fromGraph() && injected(l->impl)) l->byPlatform = true; } + // The request as written and the identity it selected; the identity is + // the canonical name the rest of the report and the output directory use. + const auto& choice = state.targetChoice; + const bool selected = !choice.identity.empty(); mcpp::ui::info("Target", tsd::format_report( - state.resolvedTargetSide, reportedTargetName, mcpp::log::is_verbose())); + state.resolvedTargetSide, selected ? choice.identity : reportedTargetName, + mcpp::log::is_verbose(), selected ? choice.request : std::string_view{}, + choice.fallback ? std::string_view(choice.why) : std::string_view{})); return {}; } diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index c8b7d17cd..bfc6dbb8a 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -36,6 +36,8 @@ import mcpp.freestanding.linkline; // the ISA profile, for the std module comman import mcpp.toolchain.post_install; import mcpp.toolchain.abi; import mcpp.toolchain.triple; +import mcpp.toolchain.target_schema; +import mcpp.toolchain.target_select; import mcpp.toolchain.base; import mcpp.toolchain.base_probe; import mcpp.home; @@ -518,6 +520,7 @@ struct TargetOverrideCtx { std::string requestedSpelling; std::optional parsed; mcpp::toolchain::triple::RequestResolution req; + mcpp::toolchain::target::Selection sel; // The `[target.]` section this request matched, or null. A // pointer rather than the map iterator the single-function version held, // since iterator validity is not this struct's business to reason about @@ -582,9 +585,62 @@ step1_resolve_target_triple_request(PrepareState& state, TargetOverrideCtx& ctx) // use. A project wanting the `planned` row keeps its escape hatch by // WRITING the segment: `--target aarch64-linux-gnu` skips completion // entirely, because a written segment is a request rather than a gap. + // THE SECTION THE REQUEST NAMES AS WRITTEN, before the selection: a + // `[target.] toolchain` is a declared family the selection keeps. + const mcpp::manifest::TargetEntry* written = nullptr; + if (auto w = state.m->targetOverrides.find(state.overrides.target_triple); + w != state.m->targetOverrides.end()) + written = &w->second; + + // THE SELECTION (SPEC-016 §4). The request is a pattern; the candidates are + // the rows it reaches with the providers that serve them, ordered by + // priority, and the first available on this host is the identity. + // Explicit choices win: a declared compiler family keeps the providers that + // pair with it, and a provider chosen by name (`builtin:`, or the + // platform package a provider names) serves alone. if (ctx.parsed) { - ctx.req = triple::resolve_request(*ctx.parsed); - ctx.parsed = ctx.req.triple; + std::string family; + auto declared = written && !written->toolchain.empty() ? std::optional(written->toolchain) + : (state.tcSpec && (state.tcFromCommandLine || tc_origin_is_user_explicit(state.tcOrigin))) + ? state.tcSpec : std::nullopt; + if (declared) + if (auto spec = mcpp::toolchain::parse_toolchain_spec(*declared)) + family = std::string(mcpp::toolchain::family_name(spec->family)); + std::vector config; + if (auto cfg = state.get_cfg(true); cfg) config = (*cfg)->platformOverrides; + const std::string provider = named_provider(state, *ctx.parsed, config); + auto sctx = target_context(state.m->platformOverrides, config, family, + provider == "none" ? std::string{} : provider, + state.msvc_usable_either_origin); + sctx.platformPackages = provider != "none"; + ctx.sel = mcpp::toolchain::target::select(state.overrides.target_triple, ctx.parsed->arch, + ctx.parsed->os, ctx.parsed->env, + ctx.parsed->envExplicit, sctx); + if (ctx.sel.row) { + if (auto chosen = triple::parse(ctx.sel.row->triple)) { + chosen->envExplicit = ctx.parsed->envExplicit; + ctx.parsed = *chosen; + } + } + // What the gates below ask of the request: the rows of its system, and + // of those the ones this mcpp builds. + ctx.req = triple::RequestResolution{}; + ctx.req.triple = *ctx.parsed; + for (auto const* r : mcpp::toolchain::target::rows_of( + ctx.parsed->arch, ctx.parsed->os, ctx.parsed->env, ctx.parsed->envExplicit)) { + ctx.req.siblings.push_back(r->triple); + if (r->tier != "planned") ctx.req.supported.push_back(r->triple); + } + ctx.req.completedFromVocabulary = ctx.sel.row && ctx.sel.row->triple != ctx.parsed->str(); + auto& choice = state.targetChoice; + choice = TargetChoice{}; + choice.request = state.overrides.target_triple; + choice.identity = ctx.parsed->str(); + choice.candidates = ctx.sel.candidates; + if (ctx.sel.provider) choice.provider = std::string(ctx.sel.provider->name); + choice.priority = ctx.sel.priority; + choice.fallback = ctx.sel.fallback; + if (ctx.sel.fallback && !ctx.sel.candidates.empty()) choice.why = ctx.sel.candidates.front().why; } // [target.X] lookup is spelling-independent: a section keyed @@ -604,6 +660,20 @@ step1_resolve_target_triple_request(PrepareState& state, TargetOverrideCtx& ctx) && !it->second.toolchain.empty(); ctx.known = ctx.parsed ? triple::find_known_target(*ctx.parsed) : nullptr; + // The row with the facts of the provider that serves it here, rather than + // of its default provider: the toolchain it pins, the C library it brings, + // the linkage and the platform package that implements it. + if (ctx.known && ctx.sel.provider && ctx.sel.provider != ctx.known->provider) { + auto view = *ctx.known; + auto const* p = ctx.sel.provider; + view.provider = p; + view.pin = p->toolchain; + view.sysroot = p->sysroot == "toolchain" ? std::string_view{} : p->sysroot; + view.defaultStatic = p->linkage == "static"; + view.platform = p->platform; + state.selectedTarget = view; + ctx.known = &*state.selectedTarget; + } return {}; } diff --git a/src/platform/platform.cppm b/src/platform/platform.cppm index ff8319c01..c27e5246e 100644 --- a/src/platform/platform.cppm +++ b/src/platform/platform.cppm @@ -38,6 +38,9 @@ struct Selection { mcpp::manifest::platform::PackageRef ref; mcpp::manifest::platform::Origin origin; bool none = true; + // A built-in provider chosen by name (`builtin:`, SPEC-016 §4): no + // platform package, and the selection takes that provider alone. + std::string builtin; // A host triple selected the platform: only its `[platform.hosted]` part // applies (§5.4). bool hosted = false; @@ -243,6 +246,13 @@ std::expected parse_choice(std::string_view value, mp::O Selection s; s.origin = std::move(origin); if (value == "none") return s; + if (value.starts_with("builtin:")) { + s.builtin = std::string(value.substr(8)); + if (s.builtin.empty()) + return std::unexpected(std::format("{}: `builtin:` names no provider; write builtin: " + "(`mcpp why target` lists them)", s.origin.key)); + return s; + } if (names_a_path(value)) { std::string dir(value.starts_with("path:") ? value.substr(5) : value); if (dir.starts_with("~")) @@ -286,6 +296,7 @@ std::expected select_target(const ChoiceInputs& in, cons s.origin = chosen->platformOrigin; if (s.origin.key.empty()) s.origin.key = std::format("[target.{}].platform", chosen->selector); + s.builtin = chosen->platformBuiltin; if (chosen->platformNone) return s; s.ref = chosen->platform; s.none = false; @@ -407,8 +418,15 @@ serves(const mp::Statement& p, std::string_view package, const mcpp::toolchain:: if (!p.hosted.targets.set) return true; std::string hosts; for (auto const& w : p.hosted.targets.value) { + // A HOSTED ENTRY NAMES A SYSTEM, AND THE C LIBRARY NAMES THE ENV + // (SPEC-016 §2, I5): `x86_64-linux-gnu` in `hosted.targets` serves + // the build of that architecture and system whose identity follows + // the C library the hosted layers bring (`x86_64-linux-musl` for + // openkal-musl), so a platform written before the identity followed + // the C library keeps serving the same builds. auto pt = mcpp::toolchain::triple::parse(w); - if (pt ? pt->str() == t.str() : w == t.str()) return true; + if (pt ? (pt->str() == t.str() || (pt->arch == t.arch && pt->os == t.os)) : w == t.str()) + return true; if (!hosts.empty()) hosts += ", "; hosts += w; } From 058f21d60bb5103b4abb048ebeb856e42bc3d425 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 06:28:09 +0900 Subject: [PATCH 08/45] target: the identity follows the C library; the selection says why a target is not served here A request that writes no env and whose graph or platform supplies a C library another row names is prepared again for that row (x86_64-linux with musl in the graph is x86_64-linux-musl); one that writes another env is refused (target-cabi-conflict). An unserved target lists its candidates, each with its provider, priority and why it is not available. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- src/build/prepare.cppm | 5 ++ src/build/prepare/driver.cpp | 13 +++- src/build/prepare/state.cppm | 2 + src/build/prepare/target_side.cpp | 49 +++++++++++++ src/build/prepare/toolchain.cpp | 56 +++++++++------ src/build/refusal.cppm | 10 +++ .../284_env_segment_is_optional_everywhere.sh | 72 ++++++++----------- 7 files changed, 143 insertions(+), 64 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 64050d600..92c48ca7f 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -883,6 +883,11 @@ export struct BuildOverrides { std::shared_ptr inherited_runtime_binding; std::string target_triple; // empty = host triple, fall through to [toolchain] + // THE REQUEST, WHEN THE IDENTITY WAS GIVEN BY THE C LIBRARY (SPEC-016 §5): a + // first pass found that the graph or the platform supplies a C library the + // selected row does not name, and prepared again for the row that names it. + // The report and the lock name this request; `target_triple` is the identity. + std::optional target_request; // --accel: the device backends and architectures this build targets, in // the wire form mcpp.pack.abi_tag reads. Overrides `[build] accel`, the // same relationship --target has with [toolchain]. diff --git a/src/build/prepare/driver.cpp b/src/build/prepare/driver.cpp index c3213ce92..09fa5efe4 100644 --- a/src/build/prepare/driver.cpp +++ b/src/build/prepare/driver.cpp @@ -90,7 +90,18 @@ prepare_build(bool print_fingerprint, return prepare_build_pass(print_fingerprint, includeDevDeps, std::move(extraTargets), std::move(overrides)); auto first = prepare_build_pass(print_fingerprint, includeDevDeps, extraTargets, overrides); - if (first) { (void)take_toolchain_restart(); (void)take_platform_restart(); return first; } + if (first) { + (void)take_toolchain_restart(); + (void)take_platform_restart(); + (void)take_target_restart(); + return first; + } + if (auto identity = take_target_restart(); identity && !overrides.target_request) { + overrides.target_request = overrides.target_triple; + overrides.target_triple = std::move(*identity); + return prepare_build(print_fingerprint, includeDevDeps, std::move(extraTargets), + std::move(overrides)); + } // A platform that states a target base, fetched by this pass: the next // reads its `[platform]` before the base is decided (SPEC-013 §7). if (auto root = take_platform_restart(); root && overrides.platform_root.empty()) { diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index f08b0cc99..ef149c96d 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -1257,6 +1257,8 @@ std::expected step13_finish_platform(PrepareState& state, Bui std::expected step2_retarget_for_retargetable_driver(PrepareState& state); // The platform root a first pass asks the second to read in P1, once. std::optional take_platform_restart(); +// The identity a first pass found the C library names (SPEC-016 §5), taken once. +std::optional take_target_restart(); std::expected load_host_platform(PrepareState& state); std::string host_platform_choice(const PrepareState& state); // The statement and bootstrap spec of a toolchain phase that asked prepare to diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp index f8e4ae12d..e0e7a103e 100644 --- a/src/build/prepare/target_side.cpp +++ b/src/build/prepare/target_side.cpp @@ -45,6 +45,7 @@ import mcpp.freestanding.linkline; // the ISA profile, for the std module comman import mcpp.toolchain.post_install; import mcpp.toolchain.abi; import mcpp.toolchain.triple; +import mcpp.toolchain.target_rows; import mcpp.build.linkage_form; // #519 — which form each dependency takes import mcpp.build.plan; import mcpp.build.flags; // compute_flags — the per-role contracts (#418) @@ -1040,6 +1041,48 @@ step9_kernel_abi_interface_enumeration(PrepareState& state) { return {}; } +namespace { +thread_local std::optional g_targetRestart; +} + +std::optional take_target_restart() { + auto r = std::move(g_targetRestart); + g_targetRestart.reset(); + return r; +} + +static std::expected cabi_identity(PrepareState& state) { + namespace tt = mcpp::toolchain::triple; + namespace tsd = mcpp::targetside; + auto const& ts = state.resolvedTargetSide; + if (ts.cAbi.absent() || !ts.cAbi.fromGraph() || ts.cAbi.interfaceName.empty()) return {}; + // A native build keeps its identity unless a platform supplies the C + // library (SPEC-016 §4.7): the graph's own C library in a native build is + // the project's arrangement, reported as such. + const bool requested = !state.overrides.target_triple.empty(); + if (!requested && !state.platform.active) return {}; + auto id = tt::parse(requested ? state.overrides.target_triple : tt::host_triple().str()); + if (!id || id->is_freestanding() || id->loader_supplies_os()) return {}; + if (tsd::c_abi_request_satisfied(id->env.empty() ? tsd::payload_libc_name(id->os, id->env) : id->env, + ts.cAbi.interfaceName)) + return {}; + const std::string env = ts.cAbi.interfaceName == "glibc" ? std::string("gnu") : ts.cAbi.interfaceName; + const auto row = std::format("{}-{}-{}", id->arch, id->os, env); + if (!mcpp::toolchain::target::find_row(row)) + return {}; + if (!state.requestedCAbi.empty() && !state.overrides.target_request) { + refusal::record(refusal::Code::TargetCabiConflict); + return std::unexpected(std::format( + "target '{}' asks for the `{}` C library, and the build links `{}` ({}).\n" + " An identity names the C library it links, so this build is `{}`.\n" + " build it as `--target {}`, or as `--target {}-{}` to let the C library name it.", + id->str(), state.requestedCAbi, ts.cAbi.interfaceName, ts.cAbi.impl, row, row, id->arch, id->os)); + } + if (state.overrides.target_request) return {}; // the second pass: the row it was given + g_targetRestart = row; + return std::unexpected(std::string("mcpp:target-restart")); +} + static std::expected step9_layering_and_requirement_checks(PrepareState& state, TargetSideGather& gather) { namespace tsd = mcpp::targetside; @@ -1093,6 +1136,12 @@ step9_layering_and_requirement_checks(PrepareState& state, TargetSideGather& gat // is the same with or without it. Refusing was tried and broke every // project spelling the host target `x86_64-linux-gnu` — which is what // `mcpp toolchain list` prints, and therefore what people write. + // THE IDENTITY FOLLOWS THE C LIBRARY (SPEC-016 §5, I5). A C library the + // graph or the platform supplies, under a system whose env names its C + // library, and a row that names it: a request that wrote no env is + // prepared again for that row; one that wrote another is refused, since + // an identity is not replaced and cannot name what is not linked. + if (auto conflict = cabi_identity(state); !conflict) return std::unexpected(conflict.error()); if (auto why = tsd::check_request(state.resolvedTargetSide)) mcpp::diag::warning("target", *why); diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index bfc6dbb8a..beb34b687 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -38,6 +38,7 @@ import mcpp.toolchain.abi; import mcpp.toolchain.triple; import mcpp.toolchain.target_schema; import mcpp.toolchain.target_select; +import mcpp.toolchain.target_rows; import mcpp.toolchain.base; import mcpp.toolchain.base_probe; import mcpp.home; @@ -634,7 +635,7 @@ step1_resolve_target_triple_request(PrepareState& state, TargetOverrideCtx& ctx) ctx.req.completedFromVocabulary = ctx.sel.row && ctx.sel.row->triple != ctx.parsed->str(); auto& choice = state.targetChoice; choice = TargetChoice{}; - choice.request = state.overrides.target_triple; + choice.request = state.overrides.target_request.value_or(state.overrides.target_triple); choice.identity = ctx.parsed->str(); choice.candidates = ctx.sel.candidates; if (ctx.sel.provider) choice.provider = std::string(ctx.sel.provider->name); @@ -965,31 +966,44 @@ step1_host_can_serve_check(PrepareState& state, TargetOverrideCtx& ctx) { // The escape hatch stays open on purpose: an explicit `[target.X]` // toolchain override means the author is supplying the cross toolchain // themselves, and mcpp's payload matrix has no standing to refuse it. + // THE SELECTION SAYS WHETHER ITS ANSWER IS AVAILABLE HERE (SPEC-016 §4.6): + // the candidate it took is the first available one, or, when none is, the + // first of all, which this diagnosis refuses unless the graph supplies the + // target's system after all (held until the graph is known). + const auto chosen = std::ranges::find_if(ctx.sel.candidates, [&](auto const& c) { + return c.row == ctx.sel.row && c.provider == ctx.sel.provider; + }); + const bool available = chosen != ctx.sel.candidates.end() ? chosen->available + : (ctx.parsed && mcpp::toolchain::host_can_serve(*ctx.parsed)); if (ctx.known && ctx.known->tier != "planned" && !ctx.hasToolchainOverride - && ctx.parsed - && !mcpp::toolchain::host_can_serve(*ctx.parsed)) { + && ctx.parsed && !available) { + std::string candidates; + for (auto const& c : ctx.sel.candidates) + candidates += std::format("\n {:<22} {:<12} priority {:<4} {}", c.row->triple, + c.provider ? c.provider->name : std::string_view("-"), c.priority, + c.available ? std::string("available") : c.why); std::string servable; - for (auto const& info : triple::known_targets()) { - auto t = triple::parse(info.canonical); - if (!t || info.tier == "planned") continue; - if (!mcpp::toolchain::host_can_serve(*t)) continue; + for (auto const* r : mcpp::toolchain::target::rows()) { + if (r->tier == "planned") continue; + auto one = mcpp::toolchain::target::select(r->triple, r->arch, r->os, r->env, true, + target_context(state.m->platformOverrides, {}, {}, {}, state.msvc_usable_either_origin)); + if (one.candidates.empty() || one.fallback || !one.candidates.front().available) continue; if (!servable.empty()) servable += ", "; - servable += t->str(); + servable += r->triple; } state.unservedTargetDiagnosis = std::format( - "target '{}' cannot be built on this host.\n" - " No toolchain payload here produces it, and nothing in " - "the dependency graph\n" - " supplies its system side.\n" - " this host can build with the payload alone: {}\n" - " To build it anyway, depend on a package that implements " - "the target's system\n" - " (its kernel interface and C library), or supply your own " - "cross toolchain with\n" - " an explicit [target.{}] toolchain = \"…\" section.", - ctx.parsed->str(), - servable.empty() ? "(nothing — `mcpp toolchain list`)" : servable, - ctx.parsed->str()); + "target '{}' cannot be built on this host: no provider of it is available here, and " + "nothing in the dependency graph supplies its system side.\n" + " candidates:{}\n" + " this host builds: {}\n" + " To build it anyway: depend on a package that implements the target's system " + "(its kernel interface and C library),\n" + " choose a platform that serves it (`--platform `), or supply your own " + "cross toolchain with [target.{}] toolchain = \"…\".\n" + " `mcpp why target {}` lists the candidates and why each is or is not available.", + ctx.parsed->str(), candidates.empty() ? std::string(" (none)") : candidates, + servable.empty() ? "(nothing — `mcpp toolchain list`)" : servable, ctx.parsed->str(), + ctx.requestedSpelling); } } diff --git a/src/build/refusal.cppm b/src/build/refusal.cppm index 6c9bcb07d..6167dfd22 100644 --- a/src/build/refusal.cppm +++ b/src/build/refusal.cppm @@ -203,6 +203,13 @@ enum class Code { // A platform's build program stated a directive outside its phase's // vocabulary (build, link, runner, runners). PlatformPhaseDirective, + // SPEC-016: no provider of the request's candidates serves it here. + TargetUnserved, + // SPEC-016 §5: the request names a C library the build does not link. + TargetCabiConflict, + // SPEC-016 §2: a platform defines a row another definition already has + // with other facts. + TargetRowConflict, Other, // a refusal that has not been given a code yet }; @@ -264,6 +271,9 @@ constexpr std::string_view name(Code c) { case Code::PlatformVersionGroup: return "platform-version-group"; case Code::PlatformPhaseDirective: return "platform-phase-directive"; + case Code::TargetUnserved: return "target-unserved"; + case Code::TargetCabiConflict: return "target-cabi-conflict"; + case Code::TargetRowConflict: return "target-row-conflict"; case Code::Other: return "other"; } return "other"; diff --git a/tests/e2e/284_env_segment_is_optional_everywhere.sh b/tests/e2e/284_env_segment_is_optional_everywhere.sh index 3c437bcdc..c92e0350a 100755 --- a/tests/e2e/284_env_segment_is_optional_everywhere.sh +++ b/tests/e2e/284_env_segment_is_optional_everywhere.sh @@ -92,7 +92,19 @@ check_pair() { rc=0 check_pair x86_64-linux x86_64-linux-gnu || rc=1 -check_pair x86_64-windows x86_64-windows-gnu || rc=1 +# `x86_64-windows` IS A PATTERN (SPEC-016 §4): its candidates are the MSVC, the +# musl (openkal-platform) and the MinGW identities in that order of priority, and +# the identity is the first available here -- MSVC on a Windows host that has it, +# the musl identity elsewhere. Whichever it is, it is one identity, one directory. +win=x86_64-windows-musl +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) + VSWHERE="/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe" + [ -f "$VSWHERE" ] && "$VSWHERE" -latest -products '*' \ + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath \ + 2>/dev/null | grep -q . && win=x86_64-windows-msvc ;; +esac +check_pair x86_64-windows "$win" || rc=1 check_pair riscv64-none riscv64-none-elf || rc=1 [ "$rc" = 0 ] || exit 1 @@ -100,21 +112,12 @@ echo "OK: the env segment is optional on every platform, and declining it change # ── The Windows C-library axis has a name of its own ───────────────────────── # -# `x86_64-windows-musl` is a target mcpp names and LLVM cannot. Measured on -# llvm ${LLVM_VERSION}, handing `windows` with a `musl` environment to clang is not a -# diagnostic but an ICE inside the COFF writer: -# -# #5 llvm::MCWinCOFFStreamer::emitCGProfileEntry(...) -# -# so mcpp's canonical form and the triple passed to the compiler must be -# different strings — which they already are, either side of the arrow in the -# build report. -# -# THE ASSERTION IS THAT IT IS A KNOWN TARGET WITH A CORRECT DIAGNOSIS, NOT -# THAT IT BUILDS. This project names no dependency, so nothing supplies the -# target side and the build cannot succeed — correctly. What is under test is -# that mcpp recognises the name and says the true thing about it, rather than -# `unknown target`, which is what it said before the row existed. +# `x86_64-windows-musl` is a target mcpp names and LLVM cannot: clang is given +# `x86_64-w64-windows-gnu`, the Itanium C++ ABI the C library was compiled for, +# and mcpp's identity names the C library. Its provider is openkal-platform's +# hosted part (SPEC-016 §3), so naming it is enough to build it: the platform +# brings openkal-musl and the LLVM run-time, and pins clang -- the one family +# that emits it. A project names no dependency for it. out="$("$MCPP" build --target x86_64-windows-musl 2>&1 || true)" case "$out" in @@ -124,32 +127,17 @@ case "$out" in esac case "$out" in - *"nothing in the dependency graph"*) - echo " ok x86_64-windows-musl is known, and the refusal names the real reason" ;; + *"Platform openkal-platform"*"built-in, hosted"*) + echo " ok x86_64-windows-musl is served by openkal-platform's hosted part" ;; *) - echo "FAIL: the refusal did not name the missing supplier" - printf '%s\n' "$out" | grep -m2 -iE 'error|Target' + echo "FAIL: the build was not served by openkal-platform" + printf '%s\n' "$out" | grep -m3 -iE 'error|Target|Platform' exit 1 ;; esac -# AND THE PIN IS ASSERTED, BECAUSE THE PIN IS WHAT THE ROW IS FOR. -# -# The row names `llvm` not as a preference but because a global default of gcc -# would otherwise be carried onto a target no gcc can emit, and the error would -# then be about a missing C++ frontend in a musl-gcc payload — a true sentence -# about the wrong subject. So the toolchain line has to show the pin winning, -# and it has to show mcpp's own name being what was asked for: -# -# Resolved llvm@${LLVM_VERSION} → x86_64-windows-musl → …/xim-x-llvm/${LLVM_VERSION}/bin/clang++ -# target default for x86_64-windows-musl, replacing your gcc@16.1.0 -# -# THIS WAS WRITTEN WITH AN `*) : ;;` FALLBACK, WHICH MADE IT UNFAILABLE. -# A branch that accepts anything is not a check, and this file's whole subject -# is a name that used to be accepted by nothing. The fallback is gone; if the -# report's phrasing changes, this is meant to go red and be updated. case "$out" in - *"→ x86_64-windows-musl →"*) - echo " ok the toolchain line carries mcpp's own name" ;; + *"→ x86_64-windows-musl →"*"llvm"*|*"llvm"*"→ x86_64-windows-musl →"*) + echo " ok the toolchain line carries mcpp's own name, and clang" ;; *) echo "FAIL: the toolchain line did not carry mcpp's own name for the target" printf '%s\n' "$out" | grep -m2 -i 'resolv' @@ -157,12 +145,12 @@ case "$out" in esac case "$out" in - *"llvm@${LLVM_VERSION}"*) - echo " ok the row's pin decided the toolchain" ;; + *"Target x86_64-windows-musl → x86_64-w64-windows-gnu"*) + echo " ok the compiler is given the Itanium triple" ;; *) - echo "FAIL: the target table's pin did not decide the toolchain" - printf '%s\n' "$out" | grep -m2 -i 'resolv' + echo "FAIL: the report does not show the compiler's triple" + printf '%s\n' "$out" | grep -m2 'Target' exit 1 ;; esac -echo "OK: mcpp names a C library LLVM cannot spell, and diagnoses it honestly" +echo "OK: mcpp names a C library LLVM cannot spell, and its provider builds it" From 43ac2736b04812800b7a478aef2ffca31cd5d6d3 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 06:47:40 +0900 Subject: [PATCH 09/45] target: a platform defines rows and providers (mcpp#911 G1, G3, G6) [[platform.define]] states a row (its triple, clang triple, object format, family, naming, descriptor blocks, execution system, ISA profile); [[platform.provider]] states what serves it, [platform] and [platform.hosted] being its shorthands. A platform's definitions are registered when it is read (early for a directory, else in a second pass after a provisional row), so the parser, cfg(os) and the selection know them. A defined row takes the platform's system rather than the host's C library; a sysroot that provides hosted-standard-library or a provider stating hosted = true gives a bare-metal row a hosted C environment. A version whose artifact is the same in every block of a descriptor is a version for any target. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- modules/manifest/src/platform.cppm | 55 +++++- modules/manifest/src/platform_toml.cppm | 120 +++++++++++- modules/manifest/src/toml.cppm | 11 +- modules/manifest/src/xpkg.cppm | 37 ++++ modules/toolchain-model/src/model.cppm | 5 + modules/toolchain-model/src/target_rows.cppm | 12 ++ .../toolchain-model/src/target_schema.cppm | 17 +- src/build/cache_key.cppm | 2 +- src/build/flags.cppm | 2 +- src/build/prepare/platform.cpp | 174 +++++++++++++++++- src/build/prepare/retarget.cpp | 21 +++ src/build/prepare/state.cppm | 10 + src/build/prepare/target_side.cpp | 6 +- src/build/prepare/toolchain.cpp | 14 +- src/freestanding/linkline.cppm | 5 +- src/freestanding/target.cppm | 5 +- src/platform/platform.cppm | 47 +++++ tests/e2e/1040_a_platform_defines_a_target.sh | 96 ++++++++++ 18 files changed, 614 insertions(+), 25 deletions(-) create mode 100644 tests/e2e/1040_a_platform_defines_a_target.sh diff --git a/modules/manifest/src/platform.cppm b/modules/manifest/src/platform.cppm index 3fa0d7a75..877abfd8d 100644 --- a/modules/manifest/src/platform.cppm +++ b/modules/manifest/src/platform.cppm @@ -228,6 +228,40 @@ struct Hosted { }; // Everything one source states about a platform, in the seven groups of §4. +// A ROW A PLATFORM DEFINES (`[[platform.define]]`, SPEC-016 §2): an identity the +// engine's table does not have, with the facts a build of it needs. +struct RowSpec { + std::string triple; + std::string tier = "preview", note, clangTriple, objectFormat = "elf", family, dataModel, + exeSuffix, sharedSuffix, loader, execution; + std::string march, mabi, codeModel, multilib, linkEmulation; + std::vector flags, descriptorKeys; + bool standard = true, exact = false; + Origin origin; +}; + +// A PROVIDER (`[[platform.provider]]`, SPEC-016 §3): the identities it serves and +// the part of the platform's statement that applies to them. `[platform]` is the +// provider of `targets`, and `[platform.hosted]` the hosted provider of its +// `targets`; both remain their shorthands. +struct ProviderSpec { + std::string name; + std::vector serves, hosts{"*"}, pairs; + int priority = 100; + std::string toolchain, sysroot, linkage; + // `platform`: the platform's own statement applies, with this provider's in + // its place; `none`: only what the provider states, and the version group -- + // the form `[platform.hosted]` is the shorthand of. + std::string base = "platform"; + // The C environment (G3): hosted (a C library with `main`), or freestanding; + // unset: hosted where a C library is present. + std::optional hosted; + std::map layers; + Link link; + std::string execution; + Origin origin; +}; + struct Statement { // [platform] Words targets; @@ -260,13 +294,22 @@ struct Statement { // [platform.hosted] Hosted hosted; + // SPEC-016: the rows the platform defines, its providers, and the priority of + // the provider `[platform]` itself is. + // The C environment the provider that applies states (G3); unset: unstated. + std::optional cHosted; + std::vector defines; + std::vector providers; + int priority = 100; + bool prioritySet = false; + bool empty() const { return !targets.set && !toolchain.set && !sysroot.set && !runner.set && runners.empty() && carriers.empty() && !descriptorKeys.set && !plugin.set && !universal.set && layers.empty() && tools.empty() && build.empty() && buildCfg.empty() && !link.driver.set && !link.flags.set && !link.start.set && !link.script.set && !link.inputsSet && !link.exe.set && !link.shared.set && !link.sealSet - && versions.empty() && !hosted.set; + && versions.empty() && !hosted.set && defines.empty() && providers.empty(); } }; @@ -301,6 +344,16 @@ constexpr bool is_layer(std::string_view k) { inline constexpr std::string_view kPlatformKeys[] = { "targets", "toolchain", "sysroot", "runner", "runners", "carrier", "plugin", "universal", "descriptor_keys", "layers", "tools", "build", "link", "versions", "hosted", + "define", "provider", "priority", +}; +inline constexpr std::string_view kDefineKeys[] = { + "triple", "tier", "note", "standard", "exact", "clang-triple", "object-format", "family", + "data-model", "exe-suffix", "shared-suffix", "loader", "execution", "march", "mabi", + "code-model", "multilib", "flags", "link-emulation", "descriptor-keys", +}; +inline constexpr std::string_view kProviderKeys[] = { + "name", "serves", "priority", "hosts", "pairs", "toolchain", "sysroot", "linkage", "base", + "hosted", "layers", "link", "execution", }; inline constexpr std::string_view kBuildKeys[] = { "cflags", "cxxflags", "asmflags", "ldflags" }; inline constexpr std::string_view kLinkKeys[] = { diff --git a/modules/manifest/src/platform_toml.cppm b/modules/manifest/src/platform_toml.cppm index df4e72383..0df584b9e 100644 --- a/modules/manifest/src/platform_toml.cppm +++ b/modules/manifest/src/platform_toml.cppm @@ -68,6 +68,8 @@ struct Reader { // `[platform]` of a platform package. std::expected statement(const mcpp::libs::toml::Table& t) const; + std::expected define(const mcpp::libs::toml::Table& t, std::string_view where) const; + std::expected provider(const mcpp::libs::toml::Table& t, std::string_view where) const; // The platform part of one `[target.]` table (the keys of // `kOverrideKeys`, plus `runner` and `runners`, which the table already @@ -549,6 +551,94 @@ std::expected Reader::carrier(const toml::Value& v, std::s return c; } +std::expected Reader::define(const toml::Table& t, std::string_view where) const { + RowSpec r; + r.origin = Origin{ tier, file.generic_string(), 0, std::string(where) }; + auto text = [&](std::string_view key, std::string& to) -> std::expected { + auto it = t.find(key); + if (it == t.end()) return {}; + if (!it->second.is_string()) + return std::unexpected(std::format("{}.{} must be a string", where, key)); + to = it->second.as_string(); + return {}; + }; + for (auto const& [k, v] : t) + if (!contains(kDefineKeys, k)) + warn(std::format("{}: '{}' is not a key of a row (ignored); the keys are {}", where, k, + list_of(kDefineKeys))); + for (auto [key, to] : std::initializer_list>{ + {"triple", &r.triple}, {"tier", &r.tier}, {"note", &r.note}, {"clang-triple", &r.clangTriple}, + {"object-format", &r.objectFormat}, {"family", &r.family}, {"data-model", &r.dataModel}, + {"exe-suffix", &r.exeSuffix}, {"shared-suffix", &r.sharedSuffix}, {"loader", &r.loader}, + {"execution", &r.execution}, {"march", &r.march}, {"mabi", &r.mabi}, + {"code-model", &r.codeModel}, {"multilib", &r.multilib}, {"link-emulation", &r.linkEmulation}}) + if (auto e = text(key, *to); !e) return std::unexpected(e.error()); + for (auto [key, to] : std::initializer_list*>>{ + {"flags", &r.flags}, {"descriptor-keys", &r.descriptorKeys}}) + if (auto it = t.find(key); it != t.end()) { + auto w = strings(it->second, std::format("{}.{}", where, key)); + if (!w) return std::unexpected(w.error()); + *to = std::move(*w); + } + for (auto [key, to] : std::initializer_list>{ + {"standard", &r.standard}, {"exact", &r.exact}}) + if (auto it = t.find(key); it != t.end()) { + if (!it->second.is_bool()) return std::unexpected(std::format("{}.{} must be true or false", where, key)); + *to = it->second.as_bool(); + } + // `arch-os` or `arch-os-env`, each segment of letters, digits, `_` and `.`. + std::size_t segments = 1; + for (char c : r.triple) segments += c == '-'; + if (r.triple.empty() || segments < 2 || segments > 3 || r.triple.starts_with('-') || r.triple.ends_with('-') + || r.triple.find("--") != std::string::npos) + return std::unexpected(std::format("{}.triple must be `arch-os` or `arch-os-env`, for example " + "\"x86_64-freebsd\"", where)); + if (r.tier != "verified" && r.tier != "preview" && r.tier != "planned") + return std::unexpected(std::format("{}.tier is verified, preview or planned", where)); + if (r.objectFormat != "elf" && r.objectFormat != "mach-o" && r.objectFormat != "pe" && r.objectFormat != "wasm") + return std::unexpected(std::format("{}.object-format is elf, mach-o, pe or wasm", where)); + return r; +} + +std::expected Reader::provider(const toml::Table& t, std::string_view where) const { + ProviderSpec p; + p.origin = Origin{ tier, file.generic_string(), 0, std::string(where) }; + for (auto const& [k, v] : t) { + const auto key = std::format("{}.{}", where, k); + if (k == "name" || k == "toolchain" || k == "sysroot" || k == "linkage" || k == "execution" + || k == "base") { + if (!v.is_string()) return std::unexpected(std::format("{} must be a string", key)); + (k == "name" ? p.name : k == "toolchain" ? p.toolchain : k == "sysroot" ? p.sysroot + : k == "linkage" ? p.linkage : k == "base" ? p.base : p.execution) = v.as_string(); + } else if (k == "serves" || k == "hosts" || k == "pairs") { + auto w = strings(v, key); + if (!w) return std::unexpected(w.error()); + (k == "serves" ? p.serves : k == "hosts" ? p.hosts : p.pairs) = std::move(*w); + } else if (k == "priority") { + if (!v.is_int()) return std::unexpected(std::format("{} must be an integer", key)); + p.priority = static_cast(v.as_int()); + } else if (k == "hosted") { + if (!v.is_bool()) return std::unexpected(std::format("{} must be true or false", key)); + p.hosted = v.as_bool(); + } else if (k == "layers") { + if (auto r = layers(v, key, p.layers); !r) return std::unexpected(r.error()); + } else if (k == "link") { + if (!v.is_table()) return std::unexpected(std::format("{} must be a table", key)); + if (auto r = link(v.as_table(), key, p.link); !r) return std::unexpected(r.error()); + } else { + warn(std::format("{}: '{}' is not a key of a provider (ignored); the keys are {}", where, k, + list_of(kProviderKeys))); + } + } + if (p.serves.empty()) + return std::unexpected(std::format("{} serves nothing: write serves = [\"\", ...]", where)); + if (!p.linkage.empty() && p.linkage != "static" && p.linkage != "dynamic") + return std::unexpected(std::format("{}.linkage is static or dynamic", where)); + if (p.base != "platform" && p.base != "none") + return std::unexpected(std::format("{}.base is \"platform\" or \"none\"", where)); + return p; +} + std::expected Reader::statement(const toml::Table& t) const { Statement s; for (auto const& [k, v] : t) { @@ -606,10 +696,13 @@ std::expected Reader::statement(const toml::Table& t) co auto w = strings(v, key); if (!w) return std::unexpected(w.error()); if (w->empty()) return std::unexpected(std::format("{} names no key", key)); + // The keys are the blocks a descriptor may have: the built-in ones, and + // any a platform's rows name (SPEC-016 §7); a key is a word. for (auto const& d : *w) - if (std::ranges::find(kDescriptorKeys, std::string_view(d)) == std::end(kDescriptorKeys)) + if (d.empty() || std::ranges::any_of(d, [](char c) { + return !std::isalnum(static_cast(c)) && c != '_' && c != '-'; })) return std::unexpected(std::format( - "{}: '{}' is not a descriptor platform; the keys are {}", key, d, + "{}: '{}' is not a descriptor block name; the built-in ones are {}", key, d, list_of(kDescriptorKeys))); s.descriptorKeys = Words{ std::move(*w), at(v, key), true }; } else if (k == "plugin") { @@ -645,6 +738,29 @@ std::expected Reader::statement(const toml::Table& t) co s.versions[vk] = vv.as_string(); } s.versionsOrigin = at(v, "[platform.versions]"); + } else if (k == "priority") { + if (!v.is_int()) + return std::unexpected(std::format("{} must be an integer", key)); + s.priority = static_cast(v.as_int()); + s.prioritySet = true; + } else if (k == "define" || k == "provider") { + if (!v.is_array()) + return std::unexpected(std::format( + "{} is an array of tables: write [[platform.{}]]", key, k)); + std::size_t i = 0; + for (auto const& e : v.as_array()) { + const auto where = std::format("[[platform.{}]] #{}", k, ++i); + if (!e.is_table()) return std::unexpected(std::format("{} must be a table", where)); + if (k == "define") { + auto r = define(e.as_table(), where); + if (!r) return std::unexpected(r.error()); + s.defines.push_back(std::move(*r)); + } else { + auto r = provider(e.as_table(), where); + if (!r) return std::unexpected(r.error()); + s.providers.push_back(std::move(*r)); + } + } } else if (k == "hosted") { if (!v.is_table()) return std::unexpected(std::format("{} must be a table", key)); s.hosted.set = true; diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index d9080fa35..35ab1d070 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -1054,6 +1054,10 @@ std::expected parse_document(const t::Document& documen // 2026-10-29 §4.5): the platform reader type-checks every entry. "platform.carrier", "target.*.carrier", + // `[[platform.define]]` and `[[platform.provider]]` (SPEC-016 §2, §3): + // the platform reader type-checks every entry. + "platform.define", + "platform.provider", }; if (auto badPath = find_disallowed_array_of_tables(doc->root(), "", kAllowedArraysOfTables)) { return std::unexpected(error(origin, std::format( @@ -1061,7 +1065,7 @@ std::expected parse_document(const t::Document& documen "array-of-tables syntax is only supported for [[build.flags]], " "[[features..flags]], [[runtime.requirements]], " "[[runtime.artifacts]], [[runtime.deploy]], [[platform.link.inputs]], " - "[[platform.carrier]] and [xlings] deps entries", + "[[platform.carrier]], [[platform.define]], [[platform.provider]] and [xlings] deps entries", *badPath, *badPath))); } @@ -3863,9 +3867,10 @@ std::expected parse_document(const t::Document& documen &m.schemaWarnings}; auto st = rd.statement(*pt); if (!st) return std::unexpected(error(origin, st.error())); - if (!st->targets.set) + if (!st->targets.set && st->providers.empty()) return std::unexpected(error(origin, - "[platform] names the targets it serves: targets = [\"\", ...]")); + "[platform] names the targets it serves: targets = [\"\", ...], or its " + "providers: [[platform.provider]] serves = [...]")); m.platform = std::move(*st); m.platformDeclared = true; } diff --git a/modules/manifest/src/xpkg.cppm b/modules/manifest/src/xpkg.cppm index c2b0571f1..70bae6560 100644 --- a/modules/manifest/src/xpkg.cppm +++ b/modules/manifest/src/xpkg.cppm @@ -1001,9 +1001,45 @@ xpkg_name_form_violation_from_lua(std::string_view luaContent) extract_xpkg_name(luaContent)); } +namespace { +std::vector +entries_of_first_block(std::string_view luaContent, + const mcpp::toolchain::platform::PlatformKey& platformAxis); +} + +// THE ARTIFACT THAT IS ONE FOR EVERY TARGET (mcpp#911 G6; SPEC-016 §7). A target +// reads the blocks its row names; when the descriptor has none of them, a +// version whose artifact is the same in every block it has -- the source archive +// of a package built from source -- is the target's too, and its versions are +// those. A prebuilt payload differs per block and stays the block's. std::vector list_xpkg_version_entries(std::string_view luaContent, const mcpp::toolchain::platform::PlatformKey& platformAxis) { + auto direct = entries_of_first_block(luaContent, platformAxis); + if (!direct.empty()) return direct; + std::vector> blocks; + for (auto key : mcpp::toolchain::platform::xpkg_platforms) { + auto one = entries_of_first_block(luaContent, + mcpp::toolchain::platform::TargetPlatform::with_keys({std::string(key)})); + if (!one.empty()) blocks.push_back(std::move(one)); + } + if (blocks.empty()) return direct; + std::vector out; + for (auto const& e : blocks.front()) { + const bool same = std::ranges::all_of(blocks, [&](auto const& b) { + auto it = std::ranges::find(b, e.version, &XpkgVersionEntry::version); + return it != b.end() && it->alias == e.alias && it->sha256 == e.sha256 + && (e.alias || !e.sha256.empty()); + }); + if (same) out.push_back(e); + } + return out; +} + +namespace { +std::vector +entries_of_first_block(std::string_view luaContent, + const mcpp::toolchain::platform::PlatformKey& platformAxis) { // Locate `xpm = { ... = { ["X.Y.Z"] = {...}, ... } ... }`, the // platform being the first of the axis's keys the table has a block for. // We work on a sanitized copy so quoted version keys remain locatable @@ -1147,6 +1183,7 @@ list_xpkg_version_entries(std::string_view luaContent, } return versions; } +} // namespace std::vector list_xpkg_versions(std::string_view luaContent, diff --git a/modules/toolchain-model/src/model.cppm b/modules/toolchain-model/src/model.cppm index 23870f190..5b6bc69a6 100644 --- a/modules/toolchain-model/src/model.cppm +++ b/modules/toolchain-model/src/model.cppm @@ -118,6 +118,11 @@ struct Toolchain { // and run-time type information off for every unit — right when nothing can // throw, and wrong when something can. bool targetCxxRuntime = false; + // THE C ENVIRONMENT IS HOSTED (mcpp#911 G3): a sysroot that provides + // `hosted-standard-library`, or a platform provider that states `hosted = + // true`, gives a bare-metal row a C library with `main`; `-ffreestanding` is + // then not added and `main` is not treated as freestanding. + bool hostedC = false; // DOES THE TARGET'S C LIBRARY COME FROM A DIRECTORY THAT EXISTED // BEFORE DEPENDENCY RESOLUTION? — `TargetSide::cAbi.prebuilt()`, recorded diff --git a/modules/toolchain-model/src/target_rows.cppm b/modules/toolchain-model/src/target_rows.cppm index 1c944e3c3..57b2a5bc3 100644 --- a/modules/toolchain-model/src/target_rows.cppm +++ b/modules/toolchain-model/src/target_rows.cppm @@ -20,6 +20,9 @@ export namespace mcpp::toolchain::target { // process does once it is defined. struct DefinitionSet { std::string source; // the platform package that stated it + // A row assumed for a request no definition knows yet, while the platform + // that may define it is fetched; a definition of the same row replaces it. + bool provisional = false; std::deque strings; // stable addresses std::deque> lists; std::vector rows; @@ -74,6 +77,15 @@ Registry& registry() { void define(DefinitionSet set) { auto& r = registry(); std::lock_guard g(r.lock); + // A source defines once per process; a provisional row gives way to a real one. + for (auto const& s : r.sets) + if (!set.source.empty() && s.source == set.source && !s.provisional && !set.provisional) return; + if (!set.provisional) + for (auto& s : r.sets) + if (s.provisional) + std::erase_if(s.rows, [&](const Row& old) { + return std::ranges::any_of(set.rows, [&](const Row& n) { return n.triple == old.triple; }); + }); std::erase_if(set.rows, [&](const Row& row) { for (auto const& b : mcpp::toolchain::builtin::kRows) if (b.triple == row.triple) return true; diff --git a/modules/toolchain-model/src/target_schema.cppm b/modules/toolchain-model/src/target_schema.cppm index 9ed998e07..7cbf5cf08 100644 --- a/modules/toolchain-model/src/target_schema.cppm +++ b/modules/toolchain-model/src/target_schema.cppm @@ -61,13 +61,20 @@ struct Provider { std::string_view platform; // a platform package reference }; -// THE PATTERN OF `serves`. `*` in the first segment stands for any arch; the -// rest of the pattern matches exactly. +// THE PATTERN OF `serves`: segment by segment, `*` standing for any one segment +// (`*-linux-musl`, `x86_64-linux-*`); a pattern and a triple of different +// numbers of segments do not match. constexpr bool serves_triple(std::string_view pattern, std::string_view triple) { if (pattern == triple) return true; - if (!pattern.starts_with("*-")) return false; - const auto dash = triple.find('-'); - return dash != std::string_view::npos && triple.substr(dash) == pattern.substr(1); + for (;;) { + const auto pd = pattern.find('-'), td = triple.find('-'); + const auto ps = pattern.substr(0, pd), ts = triple.substr(0, td); + if (ps != "*" && ps != ts) return false; + if ((pd == std::string_view::npos) != (td == std::string_view::npos)) return false; + if (pd == std::string_view::npos) return true; + pattern.remove_prefix(pd + 1); + triple.remove_prefix(td + 1); + } } constexpr bool serves(const Provider& p, std::string_view triple) { diff --git a/src/build/cache_key.cppm b/src/build/cache_key.cppm index 876e5e753..41796283c 100644 --- a/src/build/cache_key.cppm +++ b/src/build/cache_key.cppm @@ -494,7 +494,7 @@ BuildAxes build_axes(const mcpp::toolchain::Toolchain& tc, if (auto ft = mcpp::toolchain::triple::parse(tc.targetTriple)) if (auto spec = mcpp::freestanding::resolve(*ft)) b.targetImpliedFlags = - mcpp::freestanding::compile_flags(*spec, tc.targetCxxRuntime); + mcpp::freestanding::compile_flags(*spec, tc.targetCxxRuntime, tc.hostedC); b.stdlibId = tc.stdlibId; b.stdlibVersion = tc.stdlibVersion; diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 6cb371fbf..bf30e7050 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -2456,7 +2456,7 @@ CompileFlags compute_flags_impl(const BuildPlan& plan, bool packageLinkFlags) { if (auto spec = mcpp::freestanding::resolve( *mcpp::toolchain::triple::parse(plan.toolchain.targetTriple))) { const auto prefix = mcpp::freestanding::compile_prefix( - *spec, plan.toolchain.targetCxxRuntime); + *spec, plan.toolchain.targetCxxRuntime, plan.toolchain.hostedC); f.cxx += prefix; f.cc += prefix; f.as += mcpp::freestanding::assemble_prefix(*spec); diff --git a/src/build/prepare/platform.cpp b/src/build/prepare/platform.cpp index d6ed43549..2ab180aac 100644 --- a/src/build/prepare/platform.cpp +++ b/src/build/prepare/platform.cpp @@ -33,6 +33,7 @@ import mcpp.toolchain.platform; import mcpp.toolchain.target_schema; import mcpp.toolchain.target_rows; import mcpp.toolchain.target_select; +import mcpp.toolchain.builtin_targets; import mcpp.ui; import mcpp.version_req; import mcpp.pm.resolver; @@ -163,6 +164,143 @@ platform_selection_for(const mcpp::toolchain::triple::Triple& t, const BuildOver return sel->none ? std::string{} : mp::display(sel->ref); } +// THE PLATFORM'S DEFINITIONS (SPEC-016 §2, §3): its rows, its providers, and +// `[platform]` and `[platform.hosted]` as the providers they are shorthands of, +// registered for the process so the parser, the selection and `cfg(os = …)` +// know them. `ref` is how a provider names the package (`.@`, +// or `path:`). +void define_platform(const mp::Statement& s, std::string_view shortName, std::string_view ref) { + namespace target = mcpp::toolchain::target; + target::DefinitionSet set; + set.source = std::string(ref); + const auto name = mp::platform_name(shortName); + for (auto const& d : s.defines) { + target::Row r; + r.triple = set.keep(d.triple); + std::vector seg; + for (auto part : std::views::split(d.triple, '-')) seg.emplace_back(part.begin(), part.end()); + r.arch = set.keep(seg[0]); + r.os = set.keep(seg[1]); + r.env = set.keep(seg.size() > 2 ? seg[2] : std::string{}); + r.tier = set.keep(d.tier); + r.note = set.keep(d.note); + r.standard = d.standard; + r.exact = d.exact; + r.march = set.keep(d.march); + r.mabi = set.keep(d.mabi); + r.codeModel = set.keep(d.codeModel); + r.multilib = set.keep(d.multilib); + r.flags = set.keep(d.flags); + r.linkEmulation = set.keep(d.linkEmulation); + r.clangTriple = set.keep(d.clangTriple); + r.objectFormat = set.keep(d.objectFormat); + r.family = set.keep(d.family); + r.dataModel = set.keep(d.dataModel); + r.exeSuffix = set.keep(d.exeSuffix); + r.sharedSuffix = set.keep(d.sharedSuffix); + r.loader = set.keep(d.loader); + r.descriptorKeys = set.keep(d.descriptorKeys); + r.execution = set.keep(d.execution.empty() ? seg[1] : d.execution); + r.defined = true; + set.rows.push_back(r); + } + auto provider = [&](std::string pname, const std::vector& serves, int priority, + std::string_view toolchain, const std::vector& hosts, + const std::vector& pairs, std::string_view sysroot, + std::string_view linkage) { + target::Provider p; + p.name = set.keep(std::move(pname)); + p.serves = set.keep(serves); + p.priority = priority; + p.hosts = set.keep(hosts.empty() ? std::vector{"*"} : hosts); + p.pairs = set.keep(pairs); + p.toolchain = set.keep(std::string(toolchain)); + p.sysroot = set.keep(std::string(sysroot)); + p.linkage = set.keep(std::string(linkage)); + p.platform = set.keep(std::string(ref)); + set.providers.push_back(p); + }; + // A platform the engine's own providers name (openkal-platform) is served by + // those; its shorthands are not registered twice. + const bool builtin = std::ranges::any_of(mcpp::toolchain::builtin::kProviders, [&](auto const& b) { + auto r = b.platform.empty() ? std::nullopt : std::optional(mp::parse_ref_text(b.platform)); + return r && *r && (*r)->spec.shortName == shortName; + }); + for (auto const& p : s.providers) + provider(p.name.empty() ? name : p.name, p.serves, p.priority, + p.toolchain.empty() ? s.toolchain.value : p.toolchain, p.hosts, p.pairs, p.sysroot, + p.linkage); + if (s.targets.set && !builtin) + provider(name, s.targets.value, s.prioritySet ? s.priority : 100, s.toolchain.value, {}, {}, {}, {}); + // A HOSTED ENTRY NAMES A SYSTEM; THE C LIBRARY NAMES THE ENV (I5): it serves + // every identity of that architecture and system. + if (s.hosted.set && !builtin) { + std::vector serves; + for (auto const& w : s.hosted.targets.value) + if (auto t = mcpp::toolchain::triple::parse(w)) + serves.push_back(std::format("{}-{}-*", t->arch, t->os)), serves.push_back(std::format("{}-{}", t->arch, t->os)); + if (!s.hosted.targets.set) serves = {"*-*-*"}; + provider(name, serves, s.prioritySet ? s.priority - 50 : 50, + s.hosted.toolchain.set ? s.hosted.toolchain.value : s.toolchain.value, {}, {}, {}, {}); + } + target::define(std::move(set)); +} + +void define_early_platform(PrepareState& state) { + const auto raw = state.overrides.target_triple; + // The explicit choice for this request, by the words that need no parse: the + // flag, the environment and the tables whose selector is the request itself. + std::optional chosen; + std::string chosenText = !state.overrides.platform_choice.empty() ? state.overrides.platform_choice + : mcpp::invocation::get("MCPP_PLATFORM").value_or(""); + if (chosenText.empty() && !raw.empty()) { + std::string key = "MCPP_TARGET_"; + for (char c : raw) key += std::isalnum(static_cast(c)) ? char(std::toupper(static_cast(c))) : '_'; + chosenText = mcpp::invocation::get(key + "_PLATFORM").value_or(""); + } + if (!chosenText.empty()) { + if (auto sel = mcpp::platform::parse_choice(chosenText, mp::Origin{mp::Tier::Flag, {}, 0, "--platform"}); + sel && !sel->none) + chosen = sel->ref; + } else { + std::vector config; + if (auto cfg = state.get_cfg(false); cfg) config = (*cfg)->platformOverrides; + for (auto const* tables : { &state.m->platformOverrides, &config }) + for (auto const& o : *tables) + if (!chosen && o.selector == raw && o.platformSet && !o.platformNone) chosen = o.platform; + } + fs::path root = state.overrides.platform_root; + if (root.empty() && chosen && !chosen->spec.path.empty()) root = chosen->spec.path; + if (!root.empty()) + if (auto m = mcpp::manifest::load(root / "mcpp.toml"); m && m->platformDeclared) { + auto n = mcpp::pm::compat::resolve_package_name(m->package.name, m->package.namespace_); + define_platform(m->platform, n.shortName, "path:" + root.generic_string()); + } + // A request no definition knows, with a platform chosen for it that is not on + // this machine yet: a provisional row lets the first pass fetch the platform. + if (raw.empty() || mcpp::toolchain::triple::parse(raw) || !chosen) return; + std::vector seg; + for (auto part : std::views::split(std::string_view(raw), '-')) seg.emplace_back(part.begin(), part.end()); + if (seg.size() < 2 || seg.size() > 3 || std::ranges::any_of(seg, [](auto const& x) { return x.empty(); })) return; + namespace target = mcpp::toolchain::target; + target::DefinitionSet set; + set.provisional = true; + set.source = "provisional:" + raw; + target::Row r; + r.triple = set.keep(raw); + r.arch = set.keep(seg[0]); + r.os = set.keep(seg[1]); + r.env = set.keep(seg.size() > 2 ? seg[2] : std::string{}); + r.tier = set.keep("preview"); + r.objectFormat = set.keep("elf"); + r.family = set.keep("unix"); + r.execution = set.keep(seg[1]); + r.defined = true; + set.rows.push_back(r); + target::define(std::move(set)); + state.provisionalTarget = true; +} + std::string named_provider(const PrepareState& state, const mcpp::toolchain::triple::Triple& t, std::span config) { const auto flag = !state.overrides.platform_choice.empty() @@ -170,7 +308,8 @@ std::string named_provider(const PrepareState& state, const mcpp::toolchain::tri : mcpp::invocation::get("MCPP_PLATFORM").value_or(""); const auto envKey = target_env_name(t, "PLATFORM"); const auto env = mcpp::invocation::get(envKey).value_or(""); - mcpp::platform::ChoiceInputs in{ flag, env, envKey, project_tables(*state.m), config, {} }; + const auto project = project_tables(*state.m); + mcpp::platform::ChoiceInputs in{ flag, env, envKey, project, config, {} }; auto sel = mcpp::platform::select_target(in, matcher(cfgpred::context_for(t.str()))); if (!sel || sel->origin.tier == mp::Tier::BuiltIn) return {}; if (!sel->builtin.empty()) return sel->builtin; @@ -252,7 +391,7 @@ std::expected step1_select_platform(PrepareState& state) { early = state.platform.selection.ref.spec.path; if (!early.empty()) if (auto m = mcpp::manifest::load(early / "mcpp.toml"); m && m->platformDeclared) { - state.platformData = m->platform; + state.platformData = mcpp::platform::provider_view(m->platform, t.str()); state.platformEarly = true; } } @@ -351,6 +490,31 @@ std::expected step4b_apply_platform(PrepareState& state) { P.package = qualified(found->manifest); P.version = found->manifest.package.version; P.root = found->root; + define_platform(found->manifest.platform, state.platformPackageShortName, + ref.spec.path.empty() ? std::format("{}@{}", P.package, P.version) + : "path:" + ref.spec.path); + // A REQUEST ONLY A PLATFORM DEFINES (mcpp#911 G1): the first pass assumed + // the row while the platform was fetched; with its definitions read, the + // build is prepared again from the start, or refused when it defines none. + if (state.provisionalTarget) { + const auto* row = mcpp::toolchain::target::find_row(state.overrides.target_triple); + if (!row || !row->defined || std::ranges::none_of(found->manifest.platform.defines, [&](auto const& d) { + return d.triple == state.overrides.target_triple; })) { + std::string defined; + for (auto const& d : found->manifest.platform.defines) + defined += (defined.empty() ? "" : ", ") + d.triple; + refusal::record(refusal::Code::UnknownTarget); + return std::unexpected(std::format( + "unknown target '{}': no row defines it, and the platform {} ({}) defines {}.\n" + " a platform defines a target with [[platform.define]] triple = \"{}\" (SPEC-016 §2)", + state.overrides.target_triple, P.package, mcpp::platform::origin_text(P.selection.origin), + defined.empty() ? std::string("none") : defined, state.overrides.target_triple)); + } + if (!state.platformEarly) { + g_platformRestart = P.root; + return std::unexpected(std::string("mcpp:platform-restart")); + } + } auto hosted = mcpp::platform::serves(found->manifest.platform, P.package, t); if (!hosted) { refusal::record(refusal::Code::PlatformTargetUnserved); @@ -358,8 +522,10 @@ std::expected step4b_apply_platform(PrepareState& state) { mcpp::platform::origin_text(P.selection.origin))); } P.selection.hosted = *hosted; - state.platformData = *hosted ? mcpp::platform::hosted_view(found->manifest.platform) - : found->manifest.platform; + state.platformData = mcpp::platform::provider_of(found->manifest.platform, t.str()) + ? mcpp::platform::provider_view(found->manifest.platform, t.str()) + : *hosted ? mcpp::platform::hosted_view(found->manifest.platform) + : found->manifest.platform; P.fields = state.platformData; const auto ctx = platform_cfg_context(state); std::vector config; diff --git a/src/build/prepare/retarget.cpp b/src/build/prepare/retarget.cpp index e1ea16711..67071ac20 100644 --- a/src/build/prepare/retarget.cpp +++ b/src/build/prepare/retarget.cpp @@ -291,6 +291,19 @@ step2_retarget_for_retargetable_driver(PrepareState& state) { } } } + // A ROW A PLATFORM DEFINES HAS THE PLATFORM'S SYSTEM (SPEC-016 §2): the + // host's C library the toolchain's payload carries is not its, so it + // has none but the sysroot the platform or the project states. + if (auto want = mcpp::toolchain::triple::parse(state.overrides.target_triple); + want && want->defined() && !want->is_freestanding()) { + auto stmt = base_statement(state, *want); + if (!stmt || !stmt->stated()) { + state.tc->base.payload.reset(); + state.tc->base.root.clear(); + state.tc->linkRuntimeDirs.clear(); + } + if (state.platformData.cHosted) state.tc->hostedC = *state.platformData.cHosted; + } if (auto want = mcpp::toolchain::triple::parse(state.overrides.target_triple); want && want->is_freestanding()) { @@ -313,6 +326,14 @@ step2_retarget_for_retargetable_driver(PrepareState& state) { // where the other one is. state.tc->clear_std_modules(); + // THE C ENVIRONMENT IS A DECLARED FACT (mcpp#911 G3): a sysroot + // that provides the hosted standard library, or the platform's + // provider stating `hosted = true`. + if (auto stmt = base_statement(state, *want); stmt) + state.tc->hostedC = std::ranges::find(stmt->provides, "hosted-standard-library") + != stmt->provides.end(); + if (state.platformData.cHosted) state.tc->hostedC = *state.platformData.cHosted; + // ── The target's C library, resolved like its compiler ───────── // // The row in kKnownTargets names it, exactly as it names the diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index ef149c96d..658e90492 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -356,6 +356,9 @@ struct PrepareState { std::string targetRowPin; std::string targetRowName; TargetChoice targetChoice; + // The request names a row no definition knows yet, assumed while the platform + // chosen for it is fetched (mcpp#911 G1). + bool provisionalTarget = false; // The row of the identity, with the facts of the provider that serves it // (its toolchain, C library, linkage, platform) in place of the default // provider's. @@ -1234,6 +1237,13 @@ std::expected step1_select_platform(PrepareState& state); // The provider a configuration chose by name, from the explicit platform choices // (SPEC-013 §3.1 steps 1-4): `builtin:`, or the provider a chosen platform // package implements; "none" for `--platform none`; empty when nothing chose. +// Registers a platform package's rows and providers (SPEC-016 §2, §3). +void define_platform(const mcpp::manifest::platform::Statement& s, std::string_view shortName, + std::string_view ref); +// Before the request is parsed: the definitions of a platform chosen for it whose +// manifest is on this machine (a directory, or the second pass's root); and a +// provisional row for a request only a chosen platform can define. +void define_early_platform(PrepareState& state); std::string named_provider(const PrepareState& state, const mcpp::toolchain::triple::Triple& t, std::span config); diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp index e0e7a103e..9e5389acd 100644 --- a/src/build/prepare/target_side.cpp +++ b/src/build/prepare/target_side.cpp @@ -1328,8 +1328,12 @@ step9_same_os_check_and_report(PrepareState& state) { // A program its loader runs is compiled for no operating system // by design (`-unknown-none-elf`): the loader is its environment. const bool okImage = want && got && want->loader_supplies_os() && got->is_freestanding(); + // A row a platform defines states the triple its compiler is given + // (SPEC-016 §2); that triple's system is the one to compare. + const bool stated = want && got && want->defined() + && [&] { auto c = mcpp::toolchain::triple::parse(want->llvm_triple()); return c && c->os == got->os; }(); if (want && got && !want->os.empty() && !got->os.empty() - && want->os != got->os && !okImage) { + && want->os != got->os && !okImage && !stated) { refusal::record(refusal::Code::OsMismatch); return std::unexpected(std::format( "target '{}' resolved to a toolchain for '{}'.\n" diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index beb34b687..6013e6403 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -570,6 +570,7 @@ step1_resolve_target_triple_request(PrepareState& state, TargetOverrideCtx& ctx) // registered but not yet supported", a string the reader never typed // and cannot find in their own command. ctx.requestedSpelling = state.overrides.target_triple; + define_early_platform(state); ctx.parsed = triple::parse(state.overrides.target_triple); // THE REQUEST IS COMPLETED FROM THE VOCABULARY BEFORE ANYTHING @@ -1328,9 +1329,16 @@ static std::expected step1_device_axis_and_layer_merge(Prepar // The descriptor blocks the target reads (design 2026-10-29 §5.1): the // row's own, or those the platform states when it was read early (a // platform fetched in the first pass restarts the prepare so that it is). - state.targetPlatform = state.platformEarly && state.platformData.descriptorKeys.set - ? mcpp::toolchain::platform::TargetPlatform::with_keys(state.platformData.descriptorKeys.value) - : mcpp::toolchain::platform::TargetPlatform::for_os(state.cfgCtx().os); + state.targetPlatform = [&] { + if (state.platformEarly && state.platformData.descriptorKeys.set) + return mcpp::toolchain::platform::TargetPlatform::with_keys(state.platformData.descriptorKeys.value); + // A row a platform defines names its own blocks (SPEC-016 §2). + if (auto t = mcpp::toolchain::triple::parse(state.overrides.target_triple)) + if (auto* d = t->defined(); d && !d->descriptorKeys.empty()) + return mcpp::toolchain::platform::TargetPlatform::with_keys( + std::vector(d->descriptorKeys.begin(), d->descriptorKeys.end())); + return mcpp::toolchain::platform::TargetPlatform::for_os(state.cfgCtx().os); + }(); // ── L1: merge conditional [target.'cfg(...)'] sections ─────────────────── // Evaluated now (target resolved) against the resolved target — the diff --git a/src/freestanding/linkline.cppm b/src/freestanding/linkline.cppm index 4e688d7a1..71241480f 100644 --- a/src/freestanding/linkline.cppm +++ b/src/freestanding/linkline.cppm @@ -43,10 +43,11 @@ export namespace mcpp::freestanding { // `-dumpmachine`) nothing about the driver's identity says which target is // wanted. Omit it and the build silently produces host objects — measured, and // the exact shape of the E1 defect this work exists to close. -inline std::string compile_prefix(const Spec& s, bool targetCxxRuntime = false) { +inline std::string compile_prefix(const Spec& s, bool targetCxxRuntime = false, + bool hostedC = false) { std::string out; out += " --target=" + std::string(s.triple); - for (auto const& f : compile_flags(s, targetCxxRuntime)) { out += ' '; out += f; } + for (auto const& f : compile_flags(s, targetCxxRuntime, hostedC)) { out += ' '; out += f; } return out; } diff --git a/src/freestanding/target.cppm b/src/freestanding/target.cppm index 74b3731d4..ebaac784a 100644 --- a/src/freestanding/target.cppm +++ b/src/freestanding/target.cppm @@ -134,7 +134,8 @@ inline std::optional resolve(std::string_view triple) { // unconditional; the caller answers it from the capability the graph declares, // so the answer is the graph's rather than a guess about the target. inline std::vector compile_flags(const Spec& s, - bool targetCxxRuntime = false) { + bool targetCxxRuntime = false, + bool hostedC = false) { std::vector out; out.emplace_back(std::string("-march=") + std::string(s.march)); if (!s.mabi.empty()) @@ -188,7 +189,7 @@ inline std::vector compile_flags(const Spec& s, // The alternative was to make every such program write `extern "C" int // main()`, which is a workaround for a claim the build was making on the // program's behalf and that was no longer true. - if (!targetCxxRuntime) out.emplace_back("-ffreestanding"); + if (!targetCxxRuntime && !hostedC) out.emplace_back("-ffreestanding"); // UNWIND TABLES, WHICH THE COMPILER TURNS OFF FOR THIS KIND OF TARGET // AND WHICH NOTHING IN THE BUILD OTHERWISE SAYS. // diff --git a/src/platform/platform.cppm b/src/platform/platform.cppm index c27e5246e..b8d1ae1b1 100644 --- a/src/platform/platform.cppm +++ b/src/platform/platform.cppm @@ -22,6 +22,7 @@ export module mcpp.platform; import std; import mcpp.manifest; import mcpp.toolchain.triple; +import mcpp.toolchain.target_schema; import mcpp.invocation; export namespace mcpp::platform { @@ -91,6 +92,15 @@ void merge(mcpp::manifest::platform::Statement& base, const mcpp::manifest::plat // The part of a platform that applies when a host triple selects it: its // `[platform.hosted]` toolchain and layers, and nothing else (§5.4). mcpp::manifest::platform::Statement hosted_view(const mcpp::manifest::platform::Statement& platform); +// The statement that applies to `identity` (SPEC-016 §3): the platform's, with the +// fields of the `[[platform.provider]]` that serves it in place of the platform's +// own (its toolchain, layers and link), or the hosted view when that provider is +// hosted. The platform's own statement when no provider of it names the identity. +mcpp::manifest::platform::Statement provider_view(const mcpp::manifest::platform::Statement& platform, + std::string_view identity); +// The `[[platform.provider]]` of a platform that serves `identity`; null when none. +const mcpp::manifest::platform::ProviderSpec* provider_of(const mcpp::manifest::platform::Statement& platform, + std::string_view identity); // Whether the platform serves the triple, or refuses with the reason. A host // triple is served by a platform with a `[platform.hosted]` table. @@ -406,8 +416,45 @@ mp::Statement hosted_view(const mp::Statement& p) { return s; } +const mp::ProviderSpec* provider_of(const mp::Statement& p, std::string_view identity) { + for (auto const& pr : p.providers) + for (auto const& s : pr.serves) + if (mcpp::toolchain::target::serves_triple(s, identity)) return ≺ + return nullptr; +} + +mp::Statement provider_view(const mp::Statement& p, std::string_view identity) { + const auto* pr = provider_of(p, identity); + if (!pr) return p; + if (pr->base == "none") { + auto s = hosted_view(p); + s.cHosted = pr->hosted; + if (!pr->toolchain.empty()) s.toolchain = mp::Text{ pr->toolchain, pr->origin, true }; + if (!pr->layers.empty()) s.layers = pr->layers; + return s; + } + auto s = p; + s.cHosted = pr->hosted; + if (!pr->toolchain.empty()) s.toolchain = mp::Text{ pr->toolchain, pr->origin, true }; + if (!pr->layers.empty()) s.layers = pr->layers; + if (!pr->sysroot.empty()) s.sysroot = mp::Text{ pr->sysroot, pr->origin, true }; + auto const& l = pr->link; + auto text = [](mp::Text& to, const mp::Text& from) { if (from.set) to = from; }; + text(s.link.driver, l.driver); + text(s.link.start, l.start); + text(s.link.script, l.script); + text(s.link.exe, l.exe); + text(s.link.shared, l.shared); + if (l.flags.set) s.link.flags = l.flags; + if (l.inputsSet) { s.link.inputs = l.inputs; s.link.inputsOrigin = l.inputsOrigin; s.link.inputsSet = true; } + if (l.sealSet) { s.link.seal = l.seal; s.link.sealOrigin = l.sealOrigin; s.link.sealSet = true; } + return s; +} + std::expected serves(const mp::Statement& p, std::string_view package, const mcpp::toolchain::triple::Triple& t) { + // A provider of the platform that names the identity (SPEC-016 §3). + if (const auto* pr = provider_of(p, t.str())) return pr->base == "none"; for (auto const& w : p.targets.value) { auto pt = mcpp::toolchain::triple::parse(w); if (pt ? pt->str() == t.str() : w == t.str()) return false; diff --git a/tests/e2e/1040_a_platform_defines_a_target.sh b/tests/e2e/1040_a_platform_defines_a_target.sh new file mode 100644 index 000000000..4dffcfcda --- /dev/null +++ b/tests/e2e/1040_a_platform_defines_a_target.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# requires: elf llvm +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1040 -- a platform defines a target the engine's table does not have (mcpp#911 G1; SPEC-016 §2). +# +# A platform package states `[[platform.define]]` rows: the triple, the triple its compiler is +# given, the object format, the family and the system the artifact runs on. The parser then accepts +# the row's system, `cfg(os = ...)` answers by it, the output directory is named after it, and the +# platform's provider serves it. The row here is a toy system whose artifact is a static Linux ELF, +# so the host runs it. +# +# Criteria: `--target x86_64-toyos` with the platform builds into target/x86_64-toyos, the +# `cfg(os = "toyos")` table applies, the program runs (`mcpp run`) and exits as written; without +# the platform the same request is an unknown target. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +mkdir -p "$t/toy" "$t/app/src" +cat > "$t/toy/mcpp.toml" < "$t/app/mcpp.toml" <<'EOF' +[package] +name = "toyapp" +version = "0.1.0" + +[build] +sources = ["src/*.c"] +ldflags = ["-nostdlib", "-static", "-no-pie"] + +[targets.toyapp] +kind = "bin" +main = "src/main.c" + +[target.x86_64-toyos] +platform = { path = "../toy" } + +[target.'cfg(os = "toyos")'.build] +cflags = ["-DTOY_SYSTEM=1"] +EOF +cat > "$t/app/src/main.c" <<'EOF' +#ifndef TOY_SYSTEM +#error "cfg(os = \"toyos\") did not apply" +#endif +void _start(void) { + __asm__ volatile("mov $60, %eax\n mov $7, %edi\n syscall"); +} +EOF +cd "$t/app" + +out=$("$MCPP" build --target x86_64-toyos 2>&1) || fail "the platform's row did not build" "$out" +grep -q "Target x86_64-toyos → x86_64-unknown-linux-gnu" <<<"$out" || fail "the report names no clang triple of the row" "$out" +grep -q "Platform toy-platform 0.1.0" <<<"$out" || fail "the platform is not reported" "$out" +exe=$(find target/x86_64-toyos -path '*/bin/toyapp' -type f | head -1) +[ -n "$exe" ] || fail "no artifact under target/x86_64-toyos" "$(ls -R target | head -30)" +if readelf -l "$exe" | grep -q INTERP; then fail "the artifact names a program interpreter: the host's C library leaked in"; fi +set +e; "$exe"; rc=$?; set -e +[ "$rc" = 7 ] || fail "the artifact exited $rc, not 7" +echo " ok the row builds into target/x86_64-toyos, cfg(os = \"toyos\") applies, and it runs" + +set +e; run=$("$MCPP" run --target x86_64-toyos 2>&1); rc=$?; set -e +[ "$rc" = 7 ] || fail "mcpp run ended $rc, not 7" "$run" +echo " ok mcpp run runs it on the system its row names (exit 7)" + +sed -i '/^\[target.x86_64-toyos\]/,/^platform/d' mcpp.toml +out=$("$MCPP" build --target x86_64-toyos 2>&1 || true) +grep -q "unknown target 'x86_64-toyos'" <<<"$out" || fail "without the platform the request was not an unknown target" "$out" +echo " ok without the platform the request is an unknown target" +echo "PASS: 1040 a platform defines a target" From 2c81a772c46ae228e4adff733759f41dce725ec5 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 06:59:40 +0900 Subject: [PATCH 10/45] target: a platform's compiler driver links any row; an artifact for another system is not judged here; i686-none-elf (mcpp#911 G4, G5, G12) Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- modules/invocation/mcpp.toml | 2 +- modules/manifest/mcpp.toml | 2 +- modules/toolchain-model/mcpp.toml | 2 +- src/build/flags.cppm | 9 +- src/build/runtime_validation.cppm | 13 +++ tests/e2e/1040_a_platform_defines_a_target.sh | 7 ++ tests/e2e/1041_a_32_bit_x86_bare_target.sh | 85 +++++++++++++++++++ 7 files changed, 116 insertions(+), 4 deletions(-) create mode 100755 tests/e2e/1041_a_32_bit_x86_bare_target.sh diff --git a/modules/invocation/mcpp.toml b/modules/invocation/mcpp.toml index bb2ca60f9..1bb29ed19 100644 --- a/modules/invocation/mcpp.toml +++ b/modules/invocation/mcpp.toml @@ -13,4 +13,4 @@ description = "The environment of one invocation of mcpp: statements over the st license = "Apache-2.0" [dependencies] -mcpplibs.os = "0.4.0" +mcpplibs.os = "0.4.1" diff --git a/modules/manifest/mcpp.toml b/modules/manifest/mcpp.toml index d42dc2f4f..d32bc5698 100644 --- a/modules/manifest/mcpp.toml +++ b/modules/manifest/mcpp.toml @@ -24,7 +24,7 @@ mcpp.source-kind = { path = "../source-kind" } mcpp.toolchain-model = { path = "../toolchain-model" } mcpp.versioning = { path = "../versioning" } -mcpplibs.os = "0.4.0" +mcpplibs.os = "0.4.1" # Subsystem-level tests. They see only this package, which is what lets them # state its contract in isolation; the root `tests/unit/` states the facts two diff --git a/modules/toolchain-model/mcpp.toml b/modules/toolchain-model/mcpp.toml index 4704548df..31efa4d01 100644 --- a/modules/toolchain-model/mcpp.toml +++ b/modules/toolchain-model/mcpp.toml @@ -23,7 +23,7 @@ license = "Apache-2.0" mcpp.invocation = { path = "../invocation" } mcpp.versioning = { path = "../versioning" } -mcpplibs.os = "0.4.0" +mcpplibs.os = "0.4.1" # Subsystem-level tests. They see only this package, which is what lets them # state its contract in isolation; the root `tests/unit/` states the facts two diff --git a/src/build/flags.cppm b/src/build/flags.cppm index bf30e7050..b16dd7583 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -2512,7 +2512,14 @@ CompileFlags compute_flags_impl(const BuildPlan& plan, bool packageLinkFlags) { // needed either: the payload's clang config file is a DRIVER // config, and nothing here runs the driver. std::string fsLd; - if (!spec->lldEmulation.empty()) { + // A PLATFORM'S `link.driver = "compiler"` IS HONOURED ON EVERY ROW + // (mcpp#911 G4): the compiler driver then links, adding the start + // files and libraries it knows for the target, where the row alone + // would call ld.lld with its emulation. + const bool platformCompilerDriver = plan.platform.active + && plan.platform.fields.link.driver.set + && plan.platform.fields.link.driver.value == "compiler"; + if (!spec->lldEmulation.empty() && !platformCompilerDriver) { f.ldDriver = in.lld; fsLd = mcpp::freestanding::link_flags_direct(*spec, in, ninjaEsc); } else { diff --git a/src/build/runtime_validation.cppm b/src/build/runtime_validation.cppm index 9e8cbcbd7..82b8cd6d6 100644 --- a/src/build/runtime_validation.cppm +++ b/src/build/runtime_validation.cppm @@ -684,6 +684,19 @@ ValidationReport validate_changed_artifacts( record_without_rules(plan, before); return report; } + // THE ARTIFACT'S OWN SYSTEM DECIDES (mcpp#911 G5): a row a platform defines + // states where its artifacts run, and an artifact for another system is + // judged by that system's loader, not this machine's. Its closure is not + // checked here, and the build says so rather than calling it a defect. + if (auto tt = mcpp::toolchain::triple::parse(plan.toolchain.targetTriple); tt) + if (auto const* d = tt->defined(); d && d->execution != "linux") { + if (!before.empty()) + mcpp::ui::info("note", std::format( + "the artifacts are for {}, not this machine: their runtime closure is not " + "checked here", d->execution.empty() ? tt->os : std::string(d->execution))); + record_without_rules(plan, before); + return report; + } auto doc = read_cache(plan.outputDir); bool changedCache = false; diff --git a/tests/e2e/1040_a_platform_defines_a_target.sh b/tests/e2e/1040_a_platform_defines_a_target.sh index 4dffcfcda..18a3a7187 100644 --- a/tests/e2e/1040_a_platform_defines_a_target.sh +++ b/tests/e2e/1040_a_platform_defines_a_target.sh @@ -89,6 +89,13 @@ set +e; run=$("$MCPP" run --target x86_64-toyos 2>&1); rc=$?; set -e [ "$rc" = 7 ] || fail "mcpp run ended $rc, not 7" "$run" echo " ok mcpp run runs it on the system its row names (exit 7)" +# A row whose artifacts run on another system: the build does not judge their runtime closure by +# this machine's loader, and says so. +sed -i 's/^execution = "linux"/execution = "toyos"/' "$t/toy/mcpp.toml" +out=$("$MCPP" build --target x86_64-toyos 2>&1) || fail "a row for another system did not build" "$out" +grep -q "the artifacts are for toyos, not this machine" <<<"$out" || fail "the build judged a foreign artifact by this machine" "$out" +echo " ok an artifact for another system is built and its closure is not judged here" + sed -i '/^\[target.x86_64-toyos\]/,/^platform/d' mcpp.toml out=$("$MCPP" build --target x86_64-toyos 2>&1 || true) grep -q "unknown target 'x86_64-toyos'" <<<"$out" || fail "without the platform the request was not an unknown target" "$out" diff --git a/tests/e2e/1041_a_32_bit_x86_bare_target.sh b/tests/e2e/1041_a_32_bit_x86_bare_target.sh new file mode 100755 index 000000000..22ac4be01 --- /dev/null +++ b/tests/e2e/1041_a_32_bit_x86_bare_target.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# requires: elf llvm +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1041 -- 32-bit x86 without an operating system (mcpp#911 G12, G4; SPEC-016 §3). +# +# The row `i686-none-elf` compiles for the i686 instruction set and links with ld.lld directly +# under the emulation `elf_i386`. A platform package that states `link.driver = "compiler"` +# replaces the row's direct link: the compiler driver links, with the row's flags and ld.lld named +# by path. The program enters at `_start` and ends with the 32-bit system call `exit`, so an x86_64 +# Linux machine with IA-32 emulation runs it as it is. +# +# Criteria: the artifact is an ELF32 Intel 80386 executable; the link line is ld.lld's (`-m +# elf_i386`) without a platform and the compiler's (`--target=i686-none-elf ... -fuse-ld=`) with one; +# where the machine runs 32-bit programs, both artifacts exit 7. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +mkdir -p "$t/bsp" "$t/app/src" +cat > "$t/app/mcpp.toml" <<'EOF' +[package] +name = "boot32" +version = "0.1.0" + +[build] +sources = ["src/*.c"] + +[targets.boot32] +kind = "bin" +main = "src/main.c" +EOF +cat > "$t/app/src/main.c" <<'EOF' +#if !defined(__i386__) || defined(__x86_64__) +#error "not a 32-bit x86 compilation" +#endif +_Static_assert(sizeof(void*) == 4, "a 32-bit pointer"); +void _start(void) { + __asm__ volatile("mov $1, %eax\n mov $7, %ebx\n int $0x80"); +} +EOF +cd "$t/app" + +# A 32-bit program runs here when the kernel has IA-32 emulation; elsewhere the run is skipped. +runs32() { + [ "$(uname -m)" = x86_64 ] || return 1 + set +e; "$1"; local rc=$?; set -e + [ "$rc" = 7 ] && return 0 + [ "$rc" = 126 ] && return 1 + fail "the artifact exited $rc, not 7" +} +check() { # $1: what links; $2: the pattern of its link line + local exe ninja + exe=$(find target/i686-none-elf -path '*/bin/boot32' -type f | head -1) + [ -n "$exe" ] || fail "no artifact under target/i686-none-elf" + readelf -h "$exe" | grep -q 'ELF32' || fail "the artifact is not ELF32" "$(readelf -h "$exe")" + readelf -h "$exe" | grep -q 'Intel 80386' || fail "the artifact is not for i386" "$(readelf -h "$exe")" + ninja=$(find target/i686-none-elf -name build.ninja | head -1) + grep -qE "^c_ldflags += $2" "$ninja" || fail "$1 does not link" "$(grep -E '^c?_?ldflags' "$ninja")" + if runs32 "$exe"; then echo " ok $1 links an ELF32 i386 program that exits 7" + else echo " ok $1 links an ELF32 i386 program (this machine runs no 32-bit program)"; fi +} + +out=$("$MCPP" build --target i686-none-elf 2>&1) || fail "i686-none-elf did not build" "$out" +check "ld.lld under elf_i386" '-m elf_i386' + +cat > "$t/bsp/mcpp.toml" <> mcpp.toml <<'EOF' + +[target.i686-none-elf] +platform = { path = "../bsp" } +EOF +out=$("$MCPP" build --target i686-none-elf 2>&1) || fail "i686-none-elf with the platform did not build" "$out" +grep -q "Platform bsp32 0.1.0" <<<"$out" || fail "the platform is not reported" "$out" +check "the platform's compiler driver" '.*--target=i686-none-elf .*-fuse-ld=' +echo "PASS: 1041 a 32-bit x86 bare target" From fab7b9f0870aa53b47e521ff522d718720f948dd Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 07:48:02 +0900 Subject: [PATCH 11/45] =?UTF-8?q?target:=20the=20row's=20defaults=20are=20?= =?UTF-8?q?replaced=20by=20name;=20why=20target,=20why=20flags;=20the=20lo?= =?UTF-8?q?ck=20records=20a=20request's=20identity=20(mcpp#911=20G2;=20SPE?= =?UTF-8?q?C-016=20=C2=A74.5,=20=C2=A75.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `[platform.replace]` and `[target..replace]` replace the row's machine description word by word; unmet words are reported with the row's. - `mcpp why target` states the selection; `mcpp why flags []` lists each word of a unit's command line and its link line with the layer that gave it. - The build record and the plan event carry {request, identity, provider, priority, fallback}; build programs read MCPP_TARGET_PROVIDER. - mcpp.lock records `[target.""]`; `--locked` refuses a build that selects another identity. - The selection replaces `resolve_request`; the snapshot records it on three hosts. aarch64-linux-gnu precedes its musl sibling, as on x86_64. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- docs/30-build-mcpp.md | 1 + docs/zh/30-build-mcpp.md | 1 + modules/manifest/src/platform.cppm | 21 ++- modules/manifest/src/platform_toml.cppm | 29 +++ modules/manifest/src/toml.cppm | 11 +- modules/toolchain-model/data/builtin.toml | 16 +- modules/toolchain-model/src/model.cppm | 5 + .../toolchain-model/src/target_schema.cppm | 35 ++++ modules/toolchain-model/src/triple.cppm | 110 +---------- .../tests/test_target_select.cpp | 13 ++ src/build/build_program.cppm | 4 + src/build/cache_key.cppm | 3 +- src/build/execute.cppm | 11 +- src/build/flag_origins.cpp | 161 ++++++++++++++++ src/build/flag_origins.cppm | 49 +++++ src/build/flags.cppm | 10 +- src/build/hostprogram.cppm | 3 + src/build/prepare.cppm | 18 ++ src/build/prepare/build_programs.cpp | 2 + src/build/prepare/features.cpp | 1 + src/build/prepare/graph_load.cpp | 1 + src/build/prepare/local_toolchain.cpp | 1 + src/build/prepare/plan.cpp | 14 ++ src/build/prepare/records.cpp | 19 ++ src/build/prepare/retarget.cpp | 42 +++++ src/build/prepare/scan.cpp | 3 +- src/build/prepare/state.cppm | 2 + src/build/prepare/target_side.cpp | 3 +- src/build/prepare/toolchain.cpp | 3 +- src/build/result.cppm | 6 + src/cli.cppm | 6 +- src/cli/cmd_self.cppm | 11 +- src/doctor.cppm | 98 +++++++++- src/freestanding/linkline.cppm | 24 ++- src/freestanding/target.cppm | 22 ++- src/lockfile.cppm | 1 + src/platform/platform.cppm | 7 +- src/pm/lock_io.cppm | 31 +++ ..._replaced_and_each_flag_names_its_layer.sh | 71 +++++++ tests/fixtures/target-rows.snapshot | 178 ++++++++---------- tests/unit/test_freestanding.cpp | 22 +++ tests/unit/test_manifest.cpp | 29 ++- tests/unit/test_platform.cpp | 41 +++- tests/unit/test_pm_lock_io.cpp | 33 ++++ tests/unit/test_target_rows_snapshot.cpp | 24 ++- tests/unit/test_toolchain_triple.cpp | 106 +---------- 46 files changed, 943 insertions(+), 359 deletions(-) create mode 100644 src/build/flag_origins.cpp create mode 100644 src/build/flag_origins.cppm create mode 100755 tests/e2e/1042_a_row_default_is_replaced_and_each_flag_names_its_layer.sh diff --git a/docs/30-build-mcpp.md b/docs/30-build-mcpp.md index 044ea940e..e1c8116cc 100644 --- a/docs/30-build-mcpp.md +++ b/docs/30-build-mcpp.md @@ -1345,6 +1345,7 @@ The running program receives the build context as `MCPP_*` variables | `MCPP_TARGET_OS` *(0.0.100+)* | `mcpp::target_os()` | the target's OS segment (`linux`/`macos`/`windows`) — no need to hand-split `MCPP_TARGET` | | `MCPP_TARGET_ARCH` *(0.0.100+)* | `mcpp::target_arch()` | the target's arch segment (GNU spelling: `x86_64`, `aarch64`, …) | | `MCPP_TARGET_ENV` *(0.0.100+)* | `mcpp::target_env()` | the target's env segment (`gnu`/`musl`/`msvc`); empty string when the triple has none (macOS) | +| `MCPP_TARGET_PROVIDER` *(2026.11.9.1+)* | `mcpp::target_provider()` | the provider the selection gave the target's identity (`openkal`, `musl-gcc`, `msvc`, ...; SPEC-016 §4); empty string for the host's own build | | `MCPP_HOST` | `mcpp::host()` | the host triple | | `MCPP_PROFILE` | `mcpp::profile()` | effective profile name (`dev`/`release`/…) | | `MCPP_TOOLCHAIN_SYSROOT` *(2026.9.5.2+)* | `mcpp::toolchain_sysroot()` | the `--sysroot` mcpp passes to its own compiler; empty when it passes none. For a rule package that runs a **second** compiler — see "Driving a second compiler" above | diff --git a/docs/zh/30-build-mcpp.md b/docs/zh/30-build-mcpp.md index 5012d956c..ca1ab3e06 100644 --- a/docs/zh/30-build-mcpp.md +++ b/docs/zh/30-build-mcpp.md @@ -1147,6 +1147,7 @@ mcpp 会把它自己构建时用的**同一份** std 模块暂存过来,缓存 | `MCPP_TARGET_OS` *(0.0.100+)* | `mcpp::target_os()` | 目标的 OS 段(`linux`/`macos`/`windows`)——不必再手撕 `MCPP_TARGET` | | `MCPP_TARGET_ARCH` *(0.0.100+)* | `mcpp::target_arch()` | 目标的 arch 段(GNU 拼写:`x86_64`、`aarch64`…) | | `MCPP_TARGET_ENV` *(0.0.100+)* | `mcpp::target_env()` | 目标的 env 段(`gnu`/`musl`/`msvc`);三元组无 env 段(macOS)时为空串 | +| `MCPP_TARGET_PROVIDER` *(2026.11.9.1+)* | `mcpp::target_provider()` | 选择为目标身份给出的提供者(`openkal`、`musl-gcc`、`msvc` 等;SPEC-016 §4);宿主自身的构建为空串 | | `MCPP_HOST` | `mcpp::host()` | 宿主三元组 | | `MCPP_PROFILE` | `mcpp::profile()` | 生效 profile 名(`dev`/`release`/…) | | `MCPP_TOOLCHAIN_SYSROOT` *(2026.9.5.2+)* | `mcpp::toolchain_sysroot()` | mcpp 传给自己那个编译器的 `--sysroot`;不传时为空串。供运行**第二个**编译器的规则包使用 —— 见上文「驱动第二个编译器」 | diff --git a/modules/manifest/src/platform.cppm b/modules/manifest/src/platform.cppm index 877abfd8d..76784fae3 100644 --- a/modules/manifest/src/platform.cppm +++ b/modules/manifest/src/platform.cppm @@ -124,6 +124,18 @@ struct Runner { bool set = false; }; +// A word of the row's defaults and the words that stand in its place +// (`[platform.replace]`, `[target..replace]`; SPEC-016 §5.2). The +// row's defaults are its machine description -- `-march=`, `-mabi=`, +// `-mcmodel=`, its `flags` and `-m ` -- and only they are +// replaced; a convention of the engine is only added to. `with` empty: the +// word is removed. +struct Replacement { + std::string word; + std::vector with; + Origin origin; +}; + // The four flag lists of `[platform.build]` and of each of its // `[platform.build.'cfg(...)']` tables. struct Flags { @@ -288,6 +300,8 @@ struct Statement { std::vector buildCfg; // [platform.link] Link link; + // [platform.replace]: the row's defaults this statement replaces + std::vector replace; // [platform.versions] std::map versions; Origin versionsOrigin; @@ -309,7 +323,8 @@ struct Statement { && tools.empty() && build.empty() && buildCfg.empty() && !link.driver.set && !link.flags.set && !link.start.set && !link.script.set && !link.inputsSet && !link.exe.set && !link.shared.set && !link.sealSet - && versions.empty() && !hosted.set && defines.empty() && providers.empty(); + && versions.empty() && !hosted.set && defines.empty() && providers.empty() + && replace.empty(); } }; @@ -344,7 +359,7 @@ constexpr bool is_layer(std::string_view k) { inline constexpr std::string_view kPlatformKeys[] = { "targets", "toolchain", "sysroot", "runner", "runners", "carrier", "plugin", "universal", "descriptor_keys", "layers", "tools", "build", "link", "versions", "hosted", - "define", "provider", "priority", + "define", "provider", "priority", "replace", }; inline constexpr std::string_view kDefineKeys[] = { "triple", "tier", "note", "standard", "exact", "clang-triple", "object-format", "family", @@ -365,7 +380,7 @@ inline constexpr std::string_view kHostedKeys[] = { "targets", "toolchain", "lay // override a platform. `runner`, `runners`, `toolchain` and `sysroot` are the // table's existing keys and keep their parsers. inline constexpr std::string_view kOverrideKeys[] = { - "platform", "priority", "layers", "tools", "carrier", "link", + "platform", "priority", "layers", "tools", "carrier", "link", "replace", }; // The carrier entry of `s` that serves `pair` (`-`): the first whose diff --git a/modules/manifest/src/platform_toml.cppm b/modules/manifest/src/platform_toml.cppm index 0df584b9e..80ae9b113 100644 --- a/modules/manifest/src/platform_toml.cppm +++ b/modules/manifest/src/platform_toml.cppm @@ -46,6 +46,10 @@ struct Reader { std::expected link(const mcpp::libs::toml::Table& t, std::string_view where, Link& out) const; + // `replace = { "" = "" | ["", ...] | [] }`. + std::expected + replace(const mcpp::libs::toml::Value& v, std::string_view where, std::vector& out) const; + // One entry of `link.inputs`: `{ file = "..." }` or `{ from = [...], as = "..." }`. std::expected link_input(const mcpp::libs::toml::Value& e, std::string_view key) const; @@ -317,6 +321,27 @@ Reader::flags(const toml::Table& t, std::string_view where, Flags& out, return {}; } +std::expected +Reader::replace(const toml::Value& v, std::string_view where, std::vector& out) const { + if (!v.is_table()) + return std::unexpected(std::format( + "{} must be a table of = , for example \"-mcmodel=small\" = []", where)); + for (auto const& [word, with] : v.as_table()) { + const auto key = std::format("{}.\"{}\"", where, word); + Replacement r{ word, {}, at(with, key) }; + if (with.is_string()) { + if (!with.as_string().empty()) r.with.push_back(with.as_string()); + } else { + auto w = strings(with, key); + if (!w) return std::unexpected(w.error()); + r.with = std::move(*w); + } + std::erase_if(out, [&](const Replacement& e) { return e.word == r.word; }); + out.push_back(std::move(r)); + } + return {}; +} + std::expected Reader::link_input(const toml::Value& e, std::string_view key) const { if (!e.is_table()) @@ -729,6 +754,8 @@ std::expected Reader::statement(const toml::Table& t) co if (!v.is_table()) return std::unexpected(std::format("{} must be a table", key)); if (auto r = link(v.as_table(), "[platform.link]", s.link); !r) return std::unexpected(r.error()); + } else if (k == "replace") { + if (auto r = replace(v, "[platform.replace]", s.replace); !r) return std::unexpected(r.error()); } else if (k == "versions") { if (!v.is_table()) return std::unexpected(std::format("{} must be a table of = \"\"", key)); @@ -841,6 +868,8 @@ Reader::override_(const toml::Table& t, std::string_view selector) const { } else if (k == "link") { if (!v.is_table()) return std::unexpected(std::format("{} must be a table", key)); if (auto r = link(v.as_table(), key, o.fields.link); !r) return std::unexpected(r.error()); + } else if (k == "replace") { + if (auto r = replace(v, key, o.fields.replace); !r) return std::unexpected(r.error()); } else if (k == "runner") { auto r = runner(v, key); if (!r) return std::unexpected(r.error()); diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 35ab1d070..a12dcec71 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -4229,6 +4229,13 @@ std::expected parse_document(const t::Document& documen "[target.{}].priority must be a table, e.g. [target.{}.priority] mingw = 200", triple, triple))); platformKeys = true; } + // The row's defaults this selector replaces (SPEC-016 §5.2). + if (auto it = body.find("replace"); it != body.end()) { + if (!it->second.is_table()) + return std::unexpected(error(origin, std::format( + "[target.{}].replace must be a table, e.g. [target.{}.replace] \"-mcmodel=small\" = []", triple, triple))); + platformKeys = true; + } if (platformKeys || body.contains("runner") || body.contains("runners")) { mcpp::manifest::platform::Reader rd{origin, mcpp::manifest::platform::Tier::Manifest, &m.schemaWarnings}; @@ -4281,7 +4288,7 @@ std::expected parse_document(const t::Document& documen static constexpr std::string_view kKnownTargetTables[] = { "abi", "build", "build-dependencies", "carrier", "dependencies", "dev-dependencies", "feature-deps", "feature-requires-abi", - "feature-xlings", "layers", "link", "priority", "requires_abi", "runners", "runtime", + "feature-xlings", "layers", "link", "priority", "replace", "requires_abi", "runners", "runtime", "targets", "tools", "xlings", }; for (auto& [key, value] : body) { @@ -4317,7 +4324,7 @@ std::expected parse_document(const t::Document& documen "[target.{}] has unsupported key '{}' (ignored). Supported keys: " "cxx_runtime, linkage, min_api_level, platform, run_under, runner, sysroot, " "toolchain, plus the [target..runners] table for named runners and " - "the platform's layers, tools, carrier and link tables. " + "the platform's layers, tools, carrier, link, priority and replace tables. " "Per-role contracts go in [build].cxx_runtime's table form.", triple, key)); } diff --git a/modules/toolchain-model/data/builtin.toml b/modules/toolchain-model/data/builtin.toml index 464d9e4aa..07b132880 100644 --- a/modules/toolchain-model/data/builtin.toml +++ b/modules/toolchain-model/data/builtin.toml @@ -60,6 +60,15 @@ triple = "x86_64-linux-musl" tier = "verified" standard = true +# The system's standard identity before its musl sibling, as on x86_64: a +# request `aarch64-linux` takes it where its provider is available. +# `preview` until the released engine is consumed on a clean ARM64 host +# (SPEC-009 §10.7). +[[platform.define]] +triple = "aarch64-linux-gnu" +tier = "preview" +standard = true + [[platform.define]] triple = "aarch64-linux-musl" tier = "verified" @@ -109,13 +118,6 @@ triple = "riscv64-linux-musl" tier = "planned" standard = true -# `preview` until the released engine is consumed on a clean ARM64 host -# (SPEC-009 §10.7). -[[platform.define]] -triple = "aarch64-linux-gnu" -tier = "preview" -standard = true - [[platform.define]] triple = "x86_64-macos" tier = "planned" diff --git a/modules/toolchain-model/src/model.cppm b/modules/toolchain-model/src/model.cppm index 5b6bc69a6..953b95eee 100644 --- a/modules/toolchain-model/src/model.cppm +++ b/modules/toolchain-model/src/model.cppm @@ -4,6 +4,7 @@ export module mcpp.toolchain.model; import std; import mcpp.toolchain.triple; +export import mcpp.toolchain.target_schema; export import mcpp.toolchain.base; import mcpp.toolchain.platform; @@ -123,6 +124,10 @@ struct Toolchain { // true`, gives a bare-metal row a C library with `main`; `-ffreestanding` is // then not added and `main` is not treated as freestanding. bool hostedC = false; + // THE ROW'S DEFAULTS THE PLATFORM AND THE PROJECT REPLACE (mcpp#911 G2; + // SPEC-016 §5.2): `[platform.replace]` and `[target..replace]`, + // merged in their order. Read by every producer of the row's words. + std::vector rowReplace; // DOES THE TARGET'S C LIBRARY COME FROM A DIRECTORY THAT EXISTED // BEFORE DEPENDENCY RESOLUTION? — `TargetSide::cAbi.prebuilt()`, recorded diff --git a/modules/toolchain-model/src/target_schema.cppm b/modules/toolchain-model/src/target_schema.cppm index 7cbf5cf08..e12b4262f 100644 --- a/modules/toolchain-model/src/target_schema.cppm +++ b/modules/toolchain-model/src/target_schema.cppm @@ -104,4 +104,39 @@ constexpr bool available_on(const Provider& p, std::string_view host, std::strin return false; } +// A WORD OF A ROW'S DEFAULTS AND THE WORDS THAT STAND IN ITS PLACE (SPEC-016 +// §5.2). A row's defaults are its machine description: `-march=`, `-mabi=`, +// `-mcmodel=`, its `flags`, and `-m ` of a link by ld.lld itself. A +// platform or a project replaces one by naming it as the row gives it; `with` +// empty removes it. The engine's conventions are not replaced, only added to. +// `origin` names the statement, for the reports. +struct Replacement { + std::string word; + std::vector with; + std::string origin; +}; + +// `words` with each replacement applied, in order. `met`, when given, receives +// the words of the replacements that met a word of the list. +std::vector replaced(std::span words, + std::span replace, + std::set* met = nullptr); + +} // namespace mcpp::toolchain::target + +namespace mcpp::toolchain::target { + +std::vector replaced(std::span words, + std::span replace, + std::set* met) { + std::vector out; + for (auto const& w : words) { + auto r = std::ranges::find(replace, w, &Replacement::word); + if (r == replace.end()) { out.push_back(w); continue; } + if (met) met->insert(w); + out.insert(out.end(), r->with.begin(), r->with.end()); + } + return out; +} + } // namespace mcpp::toolchain::target diff --git a/modules/toolchain-model/src/triple.cppm b/modules/toolchain-model/src/triple.cppm index 177e950d6..f65893f57 100644 --- a/modules/toolchain-model/src/triple.cppm +++ b/modules/toolchain-model/src/triple.cppm @@ -595,36 +595,13 @@ inline const TargetInfo* find_known_target(const Triple& t) { inline bool is_known_target(const Triple& t) { return find_known_target(t) != nullptr; } -// ── Completing a request that declined to name a C library ────────────────── +// ── What a request reached ─────────────────────────────────────────────────── // -// `parse` FILLS THE ENV SEGMENT LEXICALLY, AND THE TIER GATE USED TO ASK -// ABOUT THE FILLED VALUE RATHER THAN ABOUT THE REQUEST. -// -// The fill is an IDENTITY operation and has to stay exactly as it is: total, -// lexical, and independent of the host (see the note on `Triple::envExplicit` -// and the one beside the fill itself). `x86_64-linux` is the identity -// `x86_64-linux-gnu` on every machine, and a unit test says so. -// -// What it is NOT is an answer to "does mcpp support this". Measured on -// 2026.8.26.1: -// -// $ mcpp build --target aarch64-linux -// error: target 'aarch64-linux-gnu' is registered but not yet supported -// $ mcpp build --target aarch64-linux-musl -// Finished dev [unoptimized + debuginfo] in 0.99s -// -// The question asked was "aarch64, Linux". The question answered was -// "aarch64-linux-GNU", and the error even quotes a triple the user never typed. -// The same fill sends `riscv64-linux` to `riscv64-linux-gnu`, a row that does -// not exist at all, so a registered target family is reported as UNKNOWN. -// -// This function is the request's own completion, applied only where a request -// is read and only when the segment was not written. It consults the vocabulary -// — compile-time data, therefore the same on every host, so target identity -// still does not depend on where the build ran. -// -// Rule one now selects the supported `aarch64-linux-gnu` lexical default. -// Its promotion from `planned` required no change to this completion logic. +// `parse` fills the env segment lexically, so the identity stays total and +// independent of the host. Which row a request is given is the selection's +// (mcpp.toolchain.target_select, SPEC-016 §4); this record is what the tier +// gates read of it: the identity taken, whether it differs from the request, +// and the rows of the request's system with those this mcpp builds. struct RequestResolution { Triple triple; // the identity to use from here on // The lexical fill was replaced by a row from the vocabulary. For the @@ -638,81 +615,6 @@ struct RequestResolution { bool ambiguous = false; }; -inline RequestResolution resolve_request(const Triple& parsed) { - RequestResolution r; - r.triple = parsed; - // A written segment is a request, not a gap: honour it, including when it - // names a `planned` row (the tier gate is what refuses that, and its - // subject is then genuinely what the user typed). - if (parsed.envExplicit || parsed.arch.empty() || parsed.os.empty()) - return r; - - const std::string prefix = parsed.arch + "-" + parsed.os; - for (auto& k : kKnownTargets) { - // Exact (macOS rows carry no env) or `arch-os-`. The separator - // check is what keeps a prefix from spanning two different OS names. - const bool exact = k.canonical == prefix; - const bool sub = k.canonical.size() > prefix.size() - && k.canonical.starts_with(prefix) - && k.canonical[prefix.size()] == '-'; - if (!exact && !sub) continue; - // A BARE `arch-os` ASKS FOR A C LIBRARY TO BE FILLED IN, AND ANDROID - // IS NOT ONE OF THE ANSWERS. - // - // The candidates here are meant to be alternatives for the SAME - // platform -- `gnu` or `musl` for a Linux -- so that `aarch64-linux` - // can complete to the one this repository supports. `android` sits on - // the same `arch-os` prefix because its kernel IS Linux, which is the - // modelling decision that makes every Linux-shaped answer in the tree - // right about it; it is not an alternative C library for the same - // platform. It has a different loader path, a different SDK and an API - // level. - // - // It became visible the moment the Android rows stopped being - // `planned`: `aarch64-linux` then had TWO supported siblings and - // resolved as ambiguous, where before it completed to - // `aarch64-linux-musl`. Either outcome of an ambiguity would be wrong - // here -- refusing a request that has an obvious answer, or answering - // it with bionic. - // - // Excluded from `siblings` too, not just from `supported`. That list is - // what the diagnostic prints, and offering `aarch64-linux-android` to - // someone who typed `aarch64-linux` would be a suggestion to build for - // a different platform. - // - // A written `aarch64-linux-android` never reaches this loop: an - // explicit env returns above, which is the rule that an author's own - // spelling is a request and not a gap. - if (auto kt = parse(k.canonical); kt && kt->is_android()) continue; - r.siblings.push_back(k.canonical); - if (k.tier != "planned") r.supported.push_back(k.canonical); - } - - const std::string lexical = parsed.str(); - for (auto s : r.supported) - if (s == lexical) return r; // rule 1: the fill is supported - - if (r.supported.size() == 1) { // rule 2: the only supported row - auto only = r.supported.front(); - r.triple.env = only.size() > prefix.size() - ? std::string(only.substr(prefix.size() + 1)) - : std::string{}; - // STILL NOT EXPLICIT. `envExplicit` records what the PROJECT asked - // for and feeds the C-library-request check; mcpp choosing a row is not - // the project naming a C library. Setting it here would make - // `check_request` compare mcpp's own answer against itself, and would - // print `aarch64-linux-musl` where the user wrote `aarch64-linux`. - r.completedFromVocabulary = true; - return r; - } - // rule 4 before rule 3: several supported rows and the fill names none. - if (r.supported.size() > 1) r.ambiguous = true; - // rule 3: nothing supported (empty group, or every row `planned`). Keep the - // lexical identity and let the caller diagnose from `siblings`, which is - // what lets the message name a row that actually exists. - return r; -} - // The effective target C library for one build. // // SINGLE READ POINT, and it is one because it was two. `prepare_build` derived diff --git a/modules/toolchain-model/tests/test_target_select.cpp b/modules/toolchain-model/tests/test_target_select.cpp index 28319f87b..17ced6af1 100644 --- a/modules/toolchain-model/tests/test_target_select.cpp +++ b/modules/toolchain-model/tests/test_target_select.cpp @@ -124,3 +124,16 @@ UTest a_planned_row_is_the_answer_only_when_alone{"TargetSelect/APlannedRowIsThe // x86_64-macos is planned; the musl identity beside it is not. expect(eq(on("macos-x86_64", {"x86_64-macos", "x86_64", "macos", ""}).identity(), "x86_64-macos-musl")); }}; + +UTest an_arm_pattern_takes_the_standard_c_library{"TargetSelect/AnArmPatternTakesTheStandardCLibrary", [] { + // The glibc row of aarch64 outranks its musl sibling, as on x86_64; a + // written env is the row it names. + auto s = on("linux-aarch64", {"aarch64-linux", "aarch64", "linux", "gnu"}); + expect(eq(s.identity(), "aarch64-linux-gnu")); + expect(eq(provider_of(s), "glibc")); + expect(!s.fallback); + const Ask written{"aarch64-linux-gnu", "aarch64", "linux", "gnu", true}; + auto w = on("linux-aarch64", written); + expect(fatal(eq(w.candidates.size(), std::size_t(1)))); + expect(eq(w.identity(), "aarch64-linux-gnu")); +}}; diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 0ed6db28e..0de58c446 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -90,6 +90,9 @@ struct BuildProgramEnv { // refused (#766). bool sealedPackage = false; std::string targetTriple; // resolved canonical triple; "" = host + // The provider the selection gave the identity (SPEC-016 §4): `openkal`, + // `musl-gcc`, `msvc`, ...; "" for the host's own build. + std::string targetProvider; // The resolved toolchain's payload root and the target's own C library // root. Both exist so a package can ASK instead of DECLARE — see // hostprogram::toolchain_dir / sysroot_dir for why declaring was wrong. @@ -678,6 +681,7 @@ contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv // it has no graph-supplied target side, so the system is always on the // link and a package should supply nothing. e.emplace_back("MCPP_TARGET_REQUESTED", env.targetTriple); + e.emplace_back("MCPP_TARGET_PROVIDER", env.targetProvider); // Convenience splits of the resolved target (Cargo CARGO_CFG_TARGET_* // parity): parsed ONCE, in install_hook_env, through the canonical triple parser so every // build.mcpp stops hand-splitting MCPP_TARGET. MCPP_TARGET_ENV is "" when diff --git a/src/build/cache_key.cppm b/src/build/cache_key.cppm index 41796283c..07f87dd86 100644 --- a/src/build/cache_key.cppm +++ b/src/build/cache_key.cppm @@ -494,7 +494,8 @@ BuildAxes build_axes(const mcpp::toolchain::Toolchain& tc, if (auto ft = mcpp::toolchain::triple::parse(tc.targetTriple)) if (auto spec = mcpp::freestanding::resolve(*ft)) b.targetImpliedFlags = - mcpp::freestanding::compile_flags(*spec, tc.targetCxxRuntime, tc.hostedC); + mcpp::freestanding::compile_flags(*spec, tc.targetCxxRuntime, tc.hostedC, + tc.rowReplace); b.stdlibId = tc.stdlibId; b.stdlibVersion = tc.stdlibVersion; diff --git a/src/build/execute.cppm b/src/build/execute.cppm index d1c01c8c2..2bffbabd8 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -1650,12 +1650,17 @@ mcpp::build::result::Record start_record(const BuildContext& ctx) { r.profile = ctx.profile; r.platform = mcpp::platform::to_json(ctx.plan.platform); r.hostPlatform = mcpp::platform::to_json(ctx.hostPlatform); + if (auto const& s = ctx.targetSelection; !s.request.empty()) + r.target = nlohmann::json{ {"request", s.request}, {"identity", s.identity}, + {"provider", s.provider}, {"priority", s.priority}, + {"fallback", s.fallback} }.dump(); mcpp::build::result::write(r); mcpp::build::result::emit("plan", {{"configuration", { {"triple", r.triple}, {"profile", r.profile}, {"fingerprint", r.fingerprint}, {"outputDir", r.outputDir.generic_string()}, {"platform", nlohmann::json::parse(r.platform, nullptr, false)}, - {"hostPlatform", nlohmann::json::parse(r.hostPlatform, nullptr, false)}}}}); + {"hostPlatform", nlohmann::json::parse(r.hostPlatform, nullptr, false)}, + {"target", nlohmann::json::parse(r.target, nullptr, false)}}}}); return r; } @@ -1703,6 +1708,7 @@ FastRecord fast_path_begin(const std::filesystem::path& projectRoot, f.record.profile = previous->profile; f.record.platform = previous->platform; f.record.hostPlatform = previous->hostPlatform; + f.record.target = previous->target; f.artifacts = std::move(previous->artifacts); } res::write(f.record); @@ -1710,7 +1716,8 @@ FastRecord fast_path_begin(const std::filesystem::path& projectRoot, {"triple", f.record.triple}, {"profile", f.record.profile}, {"fingerprint", f.record.fingerprint}, {"outputDir", f.record.outputDir.generic_string()}, {"platform", nlohmann::json::parse(f.record.platform, nullptr, false)}, - {"hostPlatform", nlohmann::json::parse(f.record.hostPlatform, nullptr, false)}}}}); + {"hostPlatform", nlohmann::json::parse(f.record.hostPlatform, nullptr, false)}, + {"target", nlohmann::json::parse(f.record.target, nullptr, false)}}}}); return f; } void fast_path_end(FastRecord& f, bool ok) { diff --git a/src/build/flag_origins.cpp b/src/build/flag_origins.cpp new file mode 100644 index 000000000..0c4719fa4 --- /dev/null +++ b/src/build/flag_origins.cpp @@ -0,0 +1,161 @@ +// mcpp.build.flag_origins — the attribution (see the interface). An +// implementation unit, so that the modules it reads are not reachable from +// the interface's importers: with them imported by the interface, GCC 16.1 +// ended with an internal compiler error (a segmentation fault) at +// `import mcpp.cli;` in main.cpp, which imports the interface through +// mcpp.doctor (measured 2026-11-09). +module mcpp.build.flag_origins; + +import std; +import nlohmann.json; +import mcpp.source_kind; +import mcpp.build.plan; +import mcpp.build.flags; +import mcpp.build.compile_commands; +import mcpp.manifest.flag_words; +import mcpp.toolchain.model; +import mcpp.toolchain.triple; +import mcpp.toolchain.target_schema; +import mcpp.freestanding.target; +import mcpp.platform; + +namespace mcpp::build { + +namespace { + +namespace ts = mcpp::toolchain::target; + +// The options whose value is the next word: one word of the explanation. +constexpr std::string_view kTakesValue[] = { + "-isystem", "-I", "-iquote", "-idirafter", "-include", "-imacros", "-x", "-Xclang", + "-Xlinker", "-B", "-L", "-T", "-m", "-MF", "-MT", "-target", "-isysroot", "-arch", + "-e", "-z", "-o", +}; + +std::vector joined(const std::vector& tokens) { + std::vector out; + for (std::size_t i = 0; i < tokens.size(); ++i) { + if (i + 1 < tokens.size() && std::ranges::find(kTakesValue, tokens[i]) != std::end(kTakesValue)) { + out.push_back(tokens[i] + " " + tokens[i + 1]); + ++i; + } else { + out.push_back(tokens[i]); + } + } + return out; +} + +struct Layers { + // The row's words as the line has them, each with the replacement it + // came from (null: the row's own). + std::map row; + std::string rowName; + std::set platform; + std::string platformName; + std::vector toolchainRoots; + std::string toolchainName; +}; + +Layers layers_of(const BuildPlan& plan) { + Layers l; + auto const& tc = plan.toolchain; + l.rowName = tc.targetTriple; + if (auto spec = mcpp::freestanding::resolve(tc.targetTriple)) { + auto words = mcpp::freestanding::row_words(*spec); + for (auto& w : mcpp::freestanding::row_link_words(*spec)) words.push_back(std::move(w)); + for (auto const& w : words) { + auto r = std::ranges::find(tc.rowReplace, w, &ts::Replacement::word); + if (r == tc.rowReplace.end()) { l.row.emplace(w, nullptr); continue; } + for (auto const& x : r->with) l.row.emplace(x, &*r); + } + } + if (plan.platform.active) { + l.platformName = plan.platform.display(); + auto const& f = plan.platform.flags; + for (auto const* list : { &f.cflags, &f.cxxflags, &f.asmflags, &f.ldflags }) + for (auto const& w : joined(mcpp::manifest::flag_words(*list))) l.platform.insert(w); + for (auto const& w : joined(mcpp::manifest::flag_words(plan.platform.fields.link.flags.value))) + l.platform.insert(w); + } + if (!tc.binaryPath.empty()) + l.toolchainRoots.push_back(tc.binaryPath.parent_path().parent_path().generic_string()); + if (!tc.base.root.empty()) l.toolchainRoots.push_back(tc.base.root.generic_string()); + l.toolchainName = tc.label(); + return l; +} + +FlagWord attribute(const Layers& l, const std::string& w) { + if (auto r = l.row.find(w); r != l.row.end()) { + if (!r->second) return { w, "row", l.rowName }; + return { w, "replace", std::format("{}, in place of `{}`", r->second->origin, r->second->word) }; + } + if (l.platform.contains(w)) return { w, "platform", l.platformName }; + for (auto const& root : l.toolchainRoots) { + auto generic = w; + std::ranges::replace(generic, '\\', '/'); + if (!root.empty() && generic.find(root) != std::string::npos) + return { w, "toolchain", l.toolchainName }; + } + return { w, "engine", {} }; +} + +} // namespace + +FlagOrigins flag_origins(const BuildPlan& plan, std::string_view unit) { + FlagOrigins o; + o.identity = plan.toolchain.targetTriple; + const auto flags = compute_flags(plan); + const auto layers = layers_of(plan); + const auto& root = plan.manifest.package.name; + for (auto const& inv : unit_invocations(plan, flags)) { + auto const& cu = *inv.unit; + const auto file = cu.source.generic_string(); + if (unit.empty() ? cu.packageName != root : !file.ends_with(unit)) continue; + UnitFlagOrigins u{ file, cu.packageName, {} }; + const bool isC = cu.kind == mcpp::SourceKind::C || cu.kind == mcpp::SourceKind::GasAsm; + const auto own = joined(isC ? mcpp::manifest::flag_words(cu.packageCflags) + : mcpp::manifest::flag_words(cu.packageCxxflags)); + const std::set ownSet(own.begin(), own.end()); + // The package's include directories, as the line spells them. + std::set includes; + for (auto inc : cu.localIncludeDirs) includes.insert("-I" + inc.make_preferred().string()); + for (auto inc : cu.localIncludeDirsAfter) includes.insert("-idirafter" + inc.make_preferred().string()); + const auto words = joined(inv.arguments); + for (std::size_t i = 0; i < words.size(); ++i) { + const auto& w = words[i]; + if (i == 0) { u.words.push_back({ w, "compiler", layers.toolchainName }); continue; } + if (w == "-c" || w == inv.file || w.starts_with("-o ")) continue; + // The defines the engine gives every unit of a target travel with + // the package's own flags. + if (w.starts_with("-D__MCPP_")) { u.words.push_back({ w, "engine", "the target's define" }); continue; } + if (ownSet.contains(w) || includes.contains(w)) { + u.words.push_back({ w, "package", cu.packageName }); + continue; + } + u.words.push_back(attribute(layers, w)); + } + o.units.push_back(std::move(u)); + } + const auto ldOwn = joined(mcpp::manifest::flag_words(plan.manifest.buildConfig.ldflags)); + const std::set ldOwnSet(ldOwn.begin(), ldOwn.end()); + for (auto const& w : joined(split_flags(flags.ld))) { + if (ldOwnSet.contains(w)) { o.link.push_back({ w, "package", root }); continue; } + o.link.push_back(attribute(layers, w)); + } + return o; +} + +nlohmann::json flag_origins_json(const FlagOrigins& o) { + auto words = [](const std::vector& ws) { + auto a = nlohmann::json::array(); + for (auto const& w : ws) + a.push_back({ { "word", w.word }, { "layer", w.layer }, { "detail", w.detail } }); + return a; + }; + auto units = nlohmann::json::array(); + for (auto const& u : o.units) + units.push_back({ { "file", u.file }, { "package", u.package }, { "words", words(u.words) } }); + return { { "identity", o.identity }, { "units", units }, { "link", words(o.link) } }; +} + +} // namespace mcpp::build diff --git a/src/build/flag_origins.cppm b/src/build/flag_origins.cppm new file mode 100644 index 000000000..703ed1a2f --- /dev/null +++ b/src/build/flag_origins.cppm @@ -0,0 +1,49 @@ +// mcpp.build.flag_origins — every word a unit is compiled with and its link +// line, each with the layer that contributed it (mcpp#911 G2; SPEC-016 §5.2). +// +// The command lines are the build's own (`unit_invocations`, `compute_flags`), +// so what is explained is what runs. A unit's line has three parts the engine +// writes in order: the package's include directories, the configuration's +// flags, and the package's own flags; the first and the last are the +// package's by construction. The configuration's flags are one string the +// engine composes from the row, the platform, the toolchain and its own +// conventions, and a word of it is attributed to the first of these that +// gives it: the row's machine description (after the replacements stated for +// it), the platform's `[platform.build]` and `[platform.link]`, a path inside +// the toolchain's payload, and otherwise the engine. A word two layers give +// is the first one's. + +export module mcpp.build.flag_origins; + +import std; +import nlohmann.json; +import mcpp.build.plan; + +export namespace mcpp::build { + +struct FlagWord { + std::string word; + std::string layer; // compiler | row | replace | platform | toolchain | package | engine + std::string detail; // the row, the statement, the package, the toolchain +}; + +struct UnitFlagOrigins { + std::string file; + std::string package; + std::vector words; +}; + +struct FlagOrigins { + std::string identity; + std::vector units; + std::vector link; // the configuration's link flags +}; + +// The units whose source path ends with `unit` (all of the root package's when +// empty), and the link flags. +FlagOrigins flag_origins(const BuildPlan& plan, std::string_view unit); + +nlohmann::json flag_origins_json(const FlagOrigins& o); + +} // namespace mcpp::build + diff --git a/src/build/flags.cppm b/src/build/flags.cppm index b16dd7583..fab91af4a 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -2456,10 +2456,11 @@ CompileFlags compute_flags_impl(const BuildPlan& plan, bool packageLinkFlags) { if (auto spec = mcpp::freestanding::resolve( *mcpp::toolchain::triple::parse(plan.toolchain.targetTriple))) { const auto prefix = mcpp::freestanding::compile_prefix( - *spec, plan.toolchain.targetCxxRuntime, plan.toolchain.hostedC); + *spec, plan.toolchain.targetCxxRuntime, plan.toolchain.hostedC, + plan.toolchain.rowReplace); f.cxx += prefix; f.cc += prefix; - f.as += mcpp::freestanding::assemble_prefix(*spec); + f.as += mcpp::freestanding::assemble_prefix(*spec, plan.toolchain.rowReplace); // ── The target's C library, from the TARGET, not from a package ── // @@ -2521,10 +2522,11 @@ CompileFlags compute_flags_impl(const BuildPlan& plan, bool packageLinkFlags) { && plan.platform.fields.link.driver.value == "compiler"; if (!spec->lldEmulation.empty() && !platformCompilerDriver) { f.ldDriver = in.lld; - fsLd = mcpp::freestanding::link_flags_direct(*spec, in, ninjaEsc); + fsLd = mcpp::freestanding::link_flags_direct(*spec, in, ninjaEsc, + plan.toolchain.rowReplace); } else { fsLd = isClangWithCfg ? " --no-default-config" : ""; - fsLd += mcpp::freestanding::link_flags(*spec, in, ninjaEsc); + fsLd += mcpp::freestanding::link_flags(*spec, in, ninjaEsc, plan.toolchain.rowReplace); } f.ld = fsLd + user_ldflags; f.ldC = f.ld; diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index 2b17a310c..dbd481a7a 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -541,6 +541,9 @@ inline const char* target() { return env_or("MCPP_TARGET") inline const char* target_os() { return env_or("MCPP_TARGET_OS"); } inline const char* target_arch() { return env_or("MCPP_TARGET_ARCH"); } inline const char* target_env() { return env_or("MCPP_TARGET_ENV"); } +// The provider of the target's identity (SPEC-016 §4): `openkal`, `musl-gcc`, ...; +// "" for the host's own build. +inline const char* target_provider() { return env_or("MCPP_TARGET_PROVIDER"); } inline const char* host() { return env_or("MCPP_HOST"); } inline const char* profile() { return env_or("MCPP_PROFILE"); } // The device axis of this build: `cuda12.9+{sm_89} ptx>=89`, or "" when the diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 92c48ca7f..c812f1082 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -458,6 +458,22 @@ export struct BuildProgramGraph { std::map programOf; }; +// One candidate of a request's selection, and the selection (SPEC-016 §4), for +// `mcpp why target` and the machine output. `request` empty: the build is the +// host's own, which no selection decides. +export struct TargetCandidate { + std::string identity, provider, why; + int priority = 0; + bool available = false; +}; + +export struct TargetSelection { + std::string request, identity, provider, why; + int priority = 0; + bool fallback = false; + std::vector candidates; +}; + export struct BuildContext { // The build programs' graphs this prepare planned to describe them // (`plan_only`; empty otherwise). @@ -592,6 +608,8 @@ export struct BuildContext { std::string replaced; // the spec displaced, when one was }; CompilerChoice compilerChoice; + // WHICH ROW AND WHICH PROVIDER THE REQUEST WAS GIVEN (SPEC-016 §4). + TargetSelection targetSelection; // THE SOURCES THIS BUILD USED (mcpp#755), one entry per subject. See // SourceDecision. std::vector sources; diff --git a/src/build/prepare/build_programs.cpp b/src/build/prepare/build_programs.cpp index f8335d737..5eac0da5e 100644 --- a/src/build/prepare/build_programs.cpp +++ b/src/build/prepare/build_programs.cpp @@ -95,6 +95,7 @@ std::expected step9_root_build_program(PrepareState& state) { if (!host) return std::unexpected(host.error()); mcpp::build::BuildProgramEnv bpEnv; bpEnv.targetTriple = state.resolvedTargetCanonical; + bpEnv.targetProvider = state.targetChoice.provider; // Everything the engine already knows and a build program would // otherwise hardcode: the payload ROOT (not the driver), the target's // C library, which compiler and which C++ standard library resolved, @@ -512,6 +513,7 @@ std::expected step9_member_build_programs(PrepareState& state auto& pkg = state.packages[i]; mcpp::build::BuildProgramEnv bpEnv; bpEnv.targetTriple = state.resolvedTargetCanonical; + bpEnv.targetProvider = state.targetChoice.provider; fill_target_build_env(bpEnv, *state.m, state.tc ? &*state.tc : nullptr, state.cfg_opt ? &*state.cfg_opt : nullptr); apply_graph_cxx_abi(bpEnv, state.packages); diff --git a/src/build/prepare/features.cpp b/src/build/prepare/features.cpp index 4d0b75e64..8f5d7df8e 100644 --- a/src/build/prepare/features.cpp +++ b/src/build/prepare/features.cpp @@ -1856,6 +1856,7 @@ static std::expected step6_dependency_build_programs(PrepareS auto [req, depDefaultFeatures] = aggregatedRequest(state, i, Instance::Target); mcpp::build::BuildProgramEnv bpEnv; bpEnv.targetTriple = state.resolvedTargetCanonical; + bpEnv.targetProvider = state.targetChoice.provider; // Everything the engine already knows and a build program would // otherwise hardcode: the payload ROOT (not the driver), the // target's C library, which compiler and which C++ standard diff --git a/src/build/prepare/graph_load.cpp b/src/build/prepare/graph_load.cpp index e17d76558..9e56048d9 100644 --- a/src/build/prepare/graph_load.cpp +++ b/src/build/prepare/graph_load.cpp @@ -1057,6 +1057,7 @@ step4a_load_version_dep_fetch(PrepareState& state, LoadVersionDepCtx& ctx) { mcpp::build::BuildProgramEnv hookEnv; fill_target_build_env(hookEnv, *state.m, state.tc ? &*state.tc : nullptr, state.cfg_opt ? &*state.cfg_opt : nullptr); hookEnv.targetTriple = state.overrides.target_triple; + hookEnv.targetProvider = state.targetChoice.provider; // Six names, fixed by install_hook_env; one guard each. const auto hookVars = mcpp::build::install_hook_env(hookEnv); mcpp::invocation::ScopedEnv hookVar0(hookVars.at(0).first, hookVars.at(0).second); diff --git a/src/build/prepare/local_toolchain.cpp b/src/build/prepare/local_toolchain.cpp index dca3de3f6..2564196fb 100644 --- a/src/build/prepare/local_toolchain.cpp +++ b/src/build/prepare/local_toolchain.cpp @@ -366,6 +366,7 @@ std::expected step6_toolchain_phase(PrepareState& state) { if (!host) return std::unexpected(host.error()); mcpp::build::BuildProgramEnv bpEnv; bpEnv.targetTriple = state.resolvedTargetCanonical; + bpEnv.targetProvider = state.targetChoice.provider; fill_target_build_env(bpEnv, *state.m, state.tc ? &*state.tc : nullptr, state.cfg_opt ? &*state.cfg_opt : nullptr); apply_graph_cxx_abi(bpEnv, state.packages); diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 556a08b3f..29bb63933 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -2188,6 +2188,20 @@ std::expected phase13_finish(PrepareState& state) { ctx.runtimeBinding = state.targetRuntimeBinding ? *state.targetRuntimeBinding : state.runtimeBindingSnapshot; ctx.runUnder = state.runUnder; + { + auto const& c = state.targetChoice; + auto& s = ctx.targetSelection; + s.request = c.request; + s.identity = c.identity.empty() ? state.tc->targetTriple : c.identity; + s.provider = c.provider; + s.priority = c.priority; + s.fallback = c.fallback; + s.why = c.why; + for (auto const& k : c.candidates) + s.candidates.push_back({ k.row ? std::string(k.row->triple) : std::string{}, + k.provider ? std::string(k.provider->name) : std::string{}, + k.why, k.priority, k.available }); + } if (!state.runUnder.empty()) { auto const& o = state.runUnderOrigin; ctx.runUnderOrigin = o.originKind == "env" ? o.originKey diff --git a/src/build/prepare/records.cpp b/src/build/prepare/records.cpp index 69958d1b4..2780532da 100644 --- a/src/build/prepare/records.cpp +++ b/src/build/prepare/records.cpp @@ -413,6 +413,19 @@ std::expected step13_lockfile(PrepareState& state, BuildConte } // One order whichever configuration wrote the file last: by identity. std::ranges::stable_sort(lock.packages, {}, [](auto const& p) { return lock_identity(p); }); + // THE IDENTITY THIS CONFIGURATION'S REQUEST WAS GIVEN (SPEC-016 §4.5), + // and the other requests' as the lock had them. + std::optional priorTarget; + { + auto const& c = state.targetChoice; + if (auto prior = mcpp::lockfile::load(state.workRoot / "mcpp.lock")) + for (auto const& t : prior->targets) { + if (!c.request.empty() && t.request == c.request) priorTarget = t; + else lock.targets.push_back(t); + } + if (!c.request.empty()) lock.targets.push_back({ c.request, c.identity, c.provider }); + std::ranges::sort(lock.targets, {}, &mcpp::lockfile::LockedTarget::request); + } if (!lock.packages.empty() || !lock.indices.empty()) { auto lockPath = state.workRoot / "mcpp.lock"; // `--locked` ASSERTS THAT THIS RESOLUTION IS THE RECORDED ONE. @@ -446,6 +459,12 @@ std::expected step13_lockfile(PrepareState& state, BuildConte for (auto const& p : prior->packages) was[key(p)] = p.version; for (auto const& p : lock.packages) now[key(p)] = p.version; std::vector drift; + if (auto const& c = state.targetChoice; priorTarget && priorTarget->identity != c.identity) + drift.push_back(std::format( + "target {}: {} ({}) -> {} ({}){}", c.request, priorTarget->identity, + priorTarget->provider.empty() ? "the platform's" : priorTarget->provider, + c.identity, c.provider.empty() ? "the platform's" : c.provider, + c.why.empty() ? std::string{} : "; " + c.why)); for (auto const& [k, v] : now) { auto it = was.find(k); if (it == was.end()) drift.push_back(k + " " + v + " (not in the lock)"); diff --git a/src/build/prepare/retarget.cpp b/src/build/prepare/retarget.cpp index 67071ac20..8a4f5baaf 100644 --- a/src/build/prepare/retarget.cpp +++ b/src/build/prepare/retarget.cpp @@ -429,4 +429,46 @@ step2_retarget_for_retargetable_driver(PrepareState& state) { return {}; } +// THE ROW'S DEFAULTS, AS THE PLATFORM AND THE PROJECT REPLACE THEM (mcpp#911 +// G2; SPEC-016 §5.2). `[platform.replace]` and `[target..replace]`, +// merged in the order of the statement every reader uses; each names a word +// the row gives. One that meets no word of the row is reported with the +// row's words, since a replacement that does nothing is a mistake the build +// cannot otherwise show; each one that applies is reported with its origin. +void step2_row_replacements(PrepareState& state) { + namespace ts = mcpp::toolchain::target; + state.tc->rowReplace.clear(); + const auto stmt = platform_statement(state); + if (stmt.replace.empty()) return; + for (auto const& r : stmt.replace) { + auto origin = mcpp::platform::origin_text(r.origin); + if (r.origin.tier == mcpp::manifest::platform::Tier::Platform && state.platform.active) + origin = state.platform.display(); + state.tc->rowReplace.push_back(ts::Replacement{ r.word, r.with, std::move(origin) }); + } + std::vector words; + if (auto spec = mcpp::freestanding::resolve(state.tc->targetTriple)) { + words = mcpp::freestanding::row_words(*spec); + for (auto& w : mcpp::freestanding::row_link_words(*spec)) words.push_back(std::move(w)); + } + std::set met; + (void)ts::replaced(words, state.tc->rowReplace, &met); + std::string given; + for (auto const& w : words) given += std::format("{}`{}`", given.empty() ? "" : ", ", w); + for (auto const& r : state.tc->rowReplace) + if (!met.contains(r.word)) + mcpp::diag::warning("target/replace", std::format( + "{} replaces `{}`, which the row {} does not give (ignored); its words are {}", + r.origin, r.word, state.tc->targetTriple, given.empty() ? std::string("none") : given)); + // In the row's order, as the command line has them. + for (auto const& w : words) { + auto r = std::ranges::find(state.tc->rowReplace, w, &ts::Replacement::word); + if (r == state.tc->rowReplace.end()) continue; + std::string with; + for (auto const& x : r->with) with += std::format("{}`{}`", with.empty() ? "" : " ", x); + mcpp::ui::info("Replaces", std::format("`{}` → {} ({})", w, + with.empty() ? std::string("nothing") : with, r->origin)); + } +} + } // namespace mcpp::build diff --git a/src/build/prepare/scan.cpp b/src/build/prepare/scan.cpp index 19e950688..ccb960316 100644 --- a/src/build/prepare/scan.cpp +++ b/src/build/prepare/scan.cpp @@ -589,7 +589,8 @@ step11_package_std_module_source(PrepareState& state) { if (auto fs = mcpp::toolchain::triple::parse(state.tc->targetTriple); fs && fs->is_freestanding()) { if (auto spec = mcpp::freestanding::resolve(*fs)) - flags += mcpp::freestanding::compile_prefix(*spec, true); + flags += mcpp::freestanding::compile_prefix(*spec, true, state.tc->hostedC, + state.tc->rowReplace); } else if (!state.tc->crossTargetFlag.empty()) { // `crossTargetFlag` and not `targetTriple`. The triple is mcpp's // vocabulary (`aarch64-macos`); the flag carries the spelling a diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index 658e90492..3095c21cd 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -1265,6 +1265,8 @@ std::vector platform_unit_cxxflags(const PrepareState& state); std::expected step13_finish_platform(PrepareState& state, BuildContext& ctx); // retarget.cpp: a retargetable driver told its target, sysroot and multilib. std::expected step2_retarget_for_retargetable_driver(PrepareState& state); +// The row's defaults the platform and the project replace (SPEC-016 §5.2). +void step2_row_replacements(PrepareState& state); // The platform root a first pass asks the second to read in P1, once. std::optional take_platform_restart(); // The identity a first pass found the C library names (SPEC-016 §5), taken once. diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp index 9e5389acd..dddfed41f 100644 --- a/src/build/prepare/target_side.cpp +++ b/src/build/prepare/target_side.cpp @@ -571,7 +571,8 @@ step9_resolve_and_realise_cabi(PrepareState& state, TargetSideGather& gather) { if (spec) { freestandingFlags.push_back( "--target=" + std::string(spec->triple)); - for (auto const& f : mcpp::freestanding::compile_flags(*spec)) + for (auto const& f : mcpp::freestanding::compile_flags( + *spec, false, false, state.tc->rowReplace)) freestandingFlags.push_back(f); } } diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index 6013e6403..3fe184658 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -580,7 +580,7 @@ step1_resolve_target_triple_request(PrepareState& state, TargetOverrideCtx& ctx) // `parse` fills a missing env segment lexically so the identity stays // total — `x86_64-linux` IS `x86_64-linux-gnu`, and a unit test says so. // Every gate below then asked about the filled value instead of about - // the request. See `triple::resolve_request` for the two measurements. + // the request. The selection (mcpp.toolchain.target_select) now answers it. // // The lookup that follows keys on `parsed->str()`, so completing after // it would match sections against a triple this build is not going to @@ -2292,6 +2292,7 @@ std::expected phase2_define_toolchain_resolver(PrepareState& if (auto r = step2_detect_toolchain(state); !r) return std::unexpected(r.error()); if (auto r = step2_apply_local_toolchain(state); !r) return std::unexpected(r.error()); if (auto r = step2_retarget_for_retargetable_driver(state); !r) return std::unexpected(r.error()); + step2_row_replacements(state); if (auto r = step2_bind_msvc_toolset(state); !r) return std::unexpected(r.error()); if (auto r = step2_resolve_base(state); !r) return std::unexpected(r.error()); if (auto r = step2_resolve_run_under(state); !r) return std::unexpected(r.error()); diff --git a/src/build/result.cppm b/src/build/result.cppm index 279ebc32b..d96faaef7 100644 --- a/src/build/result.cppm +++ b/src/build/result.cppm @@ -83,6 +83,10 @@ struct Record { // host graph's, as `mcpp::platform::to_json` writes them; "null" for none. std::string platform = "null"; std::string hostPlatform = "null"; + // The selection the configuration's identity came from (SPEC-016 §4.5): + // `{request, identity, provider, priority, fallback}`; "null" for the + // host's own build. + std::string target = "null"; std::filesystem::path outputDir; std::filesystem::path projectRoot; std::filesystem::path compileDatabase; @@ -203,6 +207,7 @@ nlohmann::json to_json(const Record& r) { {"outputDir", r.outputDir.generic_string()}, {"platform", nlohmann::json::parse(r.platform, nullptr, false)}, {"hostPlatform", nlohmann::json::parse(r.hostPlatform, nullptr, false)}, + {"target", nlohmann::json::parse(r.target, nullptr, false)}, }}, {"compileDatabase", r.compileDatabase.empty() ? nlohmann::json(nullptr) : nlohmann::json(r.compileDatabase.generic_string())}, @@ -231,6 +236,7 @@ std::optional read(const std::filesystem::path& outputDir) { r.outputDir = c.value("outputDir", ""); if (c.contains("platform")) r.platform = c["platform"].dump(); if (c.contains("hostPlatform")) r.hostPlatform = c["hostPlatform"].dump(); + if (c.contains("target")) r.target = c["target"].dump(); } if (d.contains("compileDatabase") && d["compileDatabase"].is_string()) r.compileDatabase = d["compileDatabase"].get(); diff --git a/src/cli.cppm b/src/cli.cppm index 7a025a8c9..f74547821 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -936,9 +936,9 @@ int run(int argc, char** argv) { .help("Keep unrecorded directories written more recently than this, e.g. 12h, 3d (default [build] keep_days, 3d; 0 keeps none; implies --stale)")) .action(wrap_rc(cmd_clean))) .subcommand(cl::App("why") - .description("Explain how the toolchain / runtime / deps / runners / sources were resolved") - .arg(cl::Arg("topic").help("toolchain | sysroot | runtime | deps | runners | sources | tool | payload | build-programs | compat (default: all)")) - .arg(cl::Arg("subject").help("for `tool` and `payload`: the name to look up")) + .description("Explain how the target / toolchain / runtime / deps / runners / sources / flags were resolved") + .arg(cl::Arg("topic").help("target | toolchain | sysroot | runtime | deps | runners | sources | tool | payload | build-programs | compat | flags (default: all but flags)")) + .arg(cl::Arg("subject").help("for `tool` and `payload`: the name to look up; for `flags`: the unit, by the end of its path")) // `--target` / `--toolchain` make this a QUERY rather than a // report on the current directory's default: "what would a build // for THIS pair resolve to" is the question the target matrix asks diff --git a/src/cli/cmd_self.cppm b/src/cli/cmd_self.cppm index 0d564944a..003df8910 100644 --- a/src/cli/cmd_self.cppm +++ b/src/cli/cmd_self.cppm @@ -127,11 +127,15 @@ export int cmd_why(const mcpplibs::cmdline::ParsedArgs& parsed) { if (topic == "build-programs" || topic == "compat") return mcpp::doctor::why_build_programs_or_compat_json( topic, parsed.option_or_empty("features").value()); + if (topic == "target" || topic == "flags") + return mcpp::doctor::why_target_or_flags_json( + topic, parsed.option_or_empty("target").value(), parsed.positional(1), + parsed.option_or_empty("features").value()); if (!topic.empty() && topic != "toolchain") { std::println(libc::stderr(), "error: --format json is defined for `mcpp why toolchain`, " - "`mcpp why sources|tool|payload`, `mcpp why build-programs` and " - "`mcpp why compat`; '{}' has no machine-readable " + "`mcpp why target`, `mcpp why flags`, `mcpp why sources|tool|payload`, " + "`mcpp why build-programs` and `mcpp why compat`; '{}' has no machine-readable " "shape yet", topic); return 2; } @@ -141,7 +145,8 @@ export int cmd_why(const mcpplibs::cmdline::ParsedArgs& parsed) { } return mcpp::doctor::why_report(parsed.positional(0), parsed.option_or_empty("features").value(), - parsed.positional(1)); + parsed.positional(1), + parsed.option_or_empty("target").value()); } // Also called directly by the dispatcher for the legacy `--explain CODE` form. diff --git a/src/doctor.cppm b/src/doctor.cppm index 17b1d5faf..eed48dc9d 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -16,6 +16,7 @@ import mcpp.build.refusal; import mcpp.targetside; import mcpp.wire; import mcpp.build.plan; +import mcpp.build.flag_origins; import mcpp.build.runtime_validation; import mcpp.config; import mcpp.fallback.probe_sysroot; @@ -1173,6 +1174,8 @@ int print_stored_runtime_resolution() { // // It builds nothing. `prepare_build` stops before any compile, which is also // why the target matrix can afford to ask it for every cell. +nlohmann::json target_selection_json(const mcpp::build::BuildContext& ctx); + export int why_toolchain_json(std::string_view target, std::string_view tcSpec) { mcpp::build::BuildOverrides ov; ov.target_triple = std::string(target); @@ -1281,6 +1284,7 @@ export int why_toolchain_json(std::string_view target, std::string_view tcSpec) {"replaced", ctx->compilerChoice.replaced}, }}, }; + data["target"] = target_selection_json(*ctx); data["triple"] = { {"requested", std::string(target)}, {"toolchain", tc.targetTriple}, @@ -1508,6 +1512,49 @@ nlohmann::json compat_uses_json() { return uses; } +// The selection of a request (SPEC-016 §4) as the machine output states it. +nlohmann::json target_selection_json(const mcpp::build::BuildContext& ctx) { + auto const& s = ctx.targetSelection; + auto candidates = nlohmann::json::array(); + for (auto const& c : s.candidates) + candidates.push_back({ {"identity", c.identity}, {"provider", c.provider}, + {"priority", c.priority}, {"available", c.available}, {"why", c.why} }); + return { {"request", s.request}, {"identity", s.identity}, {"provider", s.provider}, + {"priority", s.priority}, {"fallback", s.fallback}, {"why", s.why}, + {"candidates", candidates} }; +} + +// `mcpp why target|flags --format json`. +export int why_target_or_flags_json(std::string_view topic, std::string_view target, + std::string_view unit, const std::string& features) { + const bool wasQuiet = mcpp::ui::is_quiet(); + mcpp::ui::set_quiet(true); + struct QuietGuard { + bool prev; + ~QuietGuard() { mcpp::ui::set_quiet(prev); } + } quietGuard{wasQuiet}; + mcpp::build::BuildOverrides ov; + ov.features = features; + ov.target_triple = std::string(target); + auto ctx = mcpp::build::prepare_build(/*print_fingerprint=*/false, + /*includeDevDeps=*/false, {}, ov); + nlohmann::json data; + std::vector diags; + data["status"] = ctx ? "ok" : "refused"; + if (!ctx) + diags.push_back({.code = "prepare.failed", .severity = mcpp::wire::Severity::Error, + .message = ctx.error()}); + else if (topic == "target") data["target"] = target_selection_json(*ctx); + else data["flags"] = mcpp::build::flag_origins_json(mcpp::build::flag_origins(ctx->plan, unit)); + mcpp::wire::emit({ + .kind = topic == "target" ? "mcpp.why.target" : "mcpp.why.flags", + .effects = { mcpp::wire::Effect::ReadProject }, + .data = data, + .diagnostics = diags, + }); + return ctx ? 0 : 2; +} + export int why_build_programs_or_compat_json(std::string_view topic, const std::string& features) { const bool wasQuiet = mcpp::ui::is_quiet(); mcpp::ui::set_quiet(true); @@ -1537,16 +1584,16 @@ export int why_build_programs_or_compat_json(std::string_view topic, const std:: } export int why_report(const std::string& topic, const std::string& features, - const std::string& subject = {}) { + const std::string& subject = {}, const std::string& target = {}) { const bool all = topic.empty() || topic == "all"; static constexpr std::string_view kTopics[] = { - "all", "toolchain", "sysroot", "runtime", "deps", "runners", "sources", "tool", "payload", - "build-programs", "compat", + "all", "target", "toolchain", "sysroot", "runtime", "deps", "runners", "sources", "tool", + "payload", "build-programs", "compat", "flags", }; if (!topic.empty() && std::ranges::find(kTopics, std::string_view(topic)) == std::end(kTopics)) { std::println(libc::stderr(), "error: '{}' is not a topic of `mcpp why`; the topics are " - "toolchain, sysroot, runtime, deps, runners, sources, tool , " - "payload , build-programs and compat", topic); + "target, toolchain, sysroot, runtime, deps, runners, sources, tool , " + "payload , build-programs, compat and flags []", topic); return 2; } @@ -1560,11 +1607,52 @@ export int why_report(const std::string& topic, const std::string& features, // visible only in the `resolution.json` of that build. mcpp::build::BuildOverrides ov; ov.features = features; + ov.target_triple = target; auto ctx = mcpp::build::prepare_build(/*print_fingerprint=*/false, /*includeDevDeps=*/false, {}, ov); if (!ctx) { std::println(libc::stderr(), "error: {}", ctx.error()); return 2; } auto& tc = ctx->tc; + // THE SELECTION (SPEC-016 §4): the request, the identity and provider it + // was given, and every candidate in the order the rule took them. + if (all || topic == "target") { + auto const& s = ctx->targetSelection; + if (s.request.empty()) { + std::println("target: {} (the host's own build; no request to select for)", s.identity); + } else { + std::println("target: {} → {}", s.request, s.identity); + std::println(" provider: {}{}", s.provider.empty() ? std::string("(the platform's)") : s.provider, + s.provider.empty() ? std::string{} : std::format(" (priority {})", s.priority)); + if (s.fallback) std::println(" fallback: {}", s.why); + if (!s.candidates.empty()) std::println(" candidates, in order:"); + for (auto const& c : s.candidates) { + const bool taken = c.identity == s.identity && c.provider == s.provider; + std::println(" {:<26} {:<14} {:>4} {}", c.identity, + c.provider.empty() ? std::string("(platform)") : c.provider, c.priority, + taken ? std::string("taken") + : c.available ? std::string("available") : "not available: " + c.why); + } + } + } + // EVERY WORD OF A UNIT'S COMMAND LINE AND WHO GAVE IT (mcpp#911 G2). + if (topic == "flags") { + const auto o = mcpp::build::flag_origins(ctx->plan, subject); + if (o.units.empty()) + std::println("flags: no unit {}", subject.empty() ? std::string("of the root package") + : std::format("ends with '{}'", subject)); + for (auto const& u : o.units) { + std::println("flags of {} (package {}, for {}):", u.file, u.package, o.identity); + for (auto const& w : u.words) + std::println(" {:<9} {}{}", w.layer, w.word, + w.detail.empty() ? std::string{} : std::format(" [{}]", w.detail)); + } + std::println("link flags (for {}):", o.identity); + for (auto const& w : o.link) + std::println(" {:<9} {}{}", w.layer, w.word, + w.detail.empty() ? std::string{} : std::format(" [{}]", w.detail)); + return 0; + } + if (all || topic == "toolchain") { const auto prof = mcpp::toolchain::abi_profile(tc); std::println("toolchain: {}", tc.label()); diff --git a/src/freestanding/linkline.cppm b/src/freestanding/linkline.cppm index 71241480f..5d7be51e6 100644 --- a/src/freestanding/linkline.cppm +++ b/src/freestanding/linkline.cppm @@ -32,6 +32,7 @@ export module mcpp.freestanding.linkline; import std; import mcpp.freestanding.target; +import mcpp.toolchain.target_schema; export namespace mcpp::freestanding { @@ -44,16 +45,20 @@ export namespace mcpp::freestanding { // wanted. Omit it and the build silently produces host objects — measured, and // the exact shape of the E1 defect this work exists to close. inline std::string compile_prefix(const Spec& s, bool targetCxxRuntime = false, - bool hostedC = false) { + bool hostedC = false, + std::span replace = {}) { std::string out; out += " --target=" + std::string(s.triple); - for (auto const& f : compile_flags(s, targetCxxRuntime, hostedC)) { out += ' '; out += f; } + for (auto const& f : compile_flags(s, targetCxxRuntime, hostedC, replace)) { out += ' '; out += f; } return out; } // Assembly goes through the C driver, which needs the same target selection // but none of the C++ dialect flags. -inline std::string assemble_prefix(const Spec& s) { return compile_prefix(s); } +inline std::string assemble_prefix(const Spec& s, + std::span replace = {}) { + return compile_prefix(s, false, false, replace); +} // What the link line must NOT do, spelled as flags: // -nostdlib no C runtime, no default libraries @@ -78,7 +83,8 @@ struct LinkInputs { inline std::string link_flags(const Spec& s, const LinkInputs& in, const std::function& esc) + const std::filesystem::path&)>& esc, + std::span replace = {}) { std::string out; out += " --target=" + std::string(s.triple); @@ -88,7 +94,7 @@ inline std::string link_flags(const Spec& s, const LinkInputs& in, // two flags the argument governs — `-fno-exceptions`, `-fno-rtti` — say // nothing to a linker. Passing it would change no byte of the output while // suggesting it does. - for (auto const& f : compile_flags(s)) { out += ' '; out += f; } + for (auto const& f : compile_flags(s, false, false, replace)) { out += ' '; out += f; } out += " -nostdlib -nostartfiles -static"; // The link half of the pair set in mcpp.freestanding.target's // `compile_flags`. Routed through the driver, hence `-Wl,`. @@ -125,10 +131,14 @@ inline std::string link_flags(const Spec& s, const LinkInputs& in, // a link that silently drops symbols is not the same link. inline std::string link_flags_direct(const Spec& s, const LinkInputs& in, const std::function& esc) + const std::filesystem::path&)>& esc, + std::span replace = {}) { std::string out; - out += " -m " + std::string(s.lldEmulation); + for (auto const& w : mcpp::toolchain::target::replaced(row_link_words(s), replace)) { + out += ' '; + out += w; + } out += " -Bstatic"; // No loader exists, so an image must not name one. The driver line reaches // this through `-static`; here it is said to the linker. diff --git a/src/freestanding/target.cppm b/src/freestanding/target.cppm index ebaac784a..7b02dbb23 100644 --- a/src/freestanding/target.cppm +++ b/src/freestanding/target.cppm @@ -32,6 +32,7 @@ export module mcpp.freestanding.target; import std; import mcpp.toolchain.triple; import mcpp.toolchain.builtin_targets; +import mcpp.toolchain.target_schema; export namespace mcpp::freestanding { @@ -133,9 +134,10 @@ inline std::optional resolve(std::string_view triple) { // case and named it as the point at which the exception/RTTI pair stops being // unconditional; the caller answers it from the capability the graph declares, // so the answer is the graph's rather than a guess about the target. -inline std::vector compile_flags(const Spec& s, - bool targetCxxRuntime = false, - bool hostedC = false) { +// THE ROW'S OWN WORDS: its machine description, which a platform or a project +// may replace (SPEC-016 §5.2); everything `compile_flags` adds after them is the +// engine's convention. +inline std::vector row_words(const Spec& s) { std::vector out; out.emplace_back(std::string("-march=") + std::string(s.march)); if (!s.mabi.empty()) @@ -146,6 +148,20 @@ inline std::vector compile_flags(const Spec& s, // `-ffreestanding` so the ordering of this function's output stays a // function of the table rather than of the row. for (auto flag : s.extra) out.emplace_back(flag); + return out; +} + +// The row's own words of a link by ld.lld itself: the emulation, one word. +inline std::vector row_link_words(const Spec& s) { + if (s.lldEmulation.empty()) return {}; + return { "-m " + std::string(s.lldEmulation) }; +} + +inline std::vector compile_flags(const Spec& s, + bool targetCxxRuntime = false, + bool hostedC = false, + std::span replace = {}) { + std::vector out = mcpp::toolchain::target::replaced(row_words(s), replace); // ONE SECTION PER FUNCTION, SO THE LINKER CAN DROP WHAT NOTHING CALLS. // // These two flags do nothing on their own; they are the half of diff --git a/src/lockfile.cppm b/src/lockfile.cppm index b90aa00e9..1873e6ea9 100644 --- a/src/lockfile.cppm +++ b/src/lockfile.cppm @@ -17,6 +17,7 @@ export namespace mcpp::lockfile { using LockedIndex = mcpp::pm::LockedIndex; using LockedPackage = mcpp::pm::LockedPackage; +using LockedTarget = mcpp::pm::LockedTarget; using Lockfile = mcpp::pm::Lockfile; using LockError = mcpp::pm::LockError; diff --git a/src/platform/platform.cppm b/src/platform/platform.cppm index b8d1ae1b1..32874f270 100644 --- a/src/platform/platform.cppm +++ b/src/platform/platform.cppm @@ -86,7 +86,7 @@ std::expected select_host(std::string_view flagOrEnv, bo // concatenate (the platform's first, so a convention cannot be removed, only // added to), and `link.inputs` and `link.seal` are replaced whole (they are // one sequence each). Maps (`layers`, `tools`, `runners`, `versions`) are -// merged key by key. +// merged key by key, and so are the replacements of a row's words (`replace`). void merge(mcpp::manifest::platform::Statement& base, const mcpp::manifest::platform::Statement& over); // The part of a platform that applies when a host triple selects it: its @@ -397,6 +397,11 @@ void merge(mp::Statement& b, const mp::Statement& o) { b.link.sealOrigin = o.link.sealOrigin; b.link.sealSet = true; } + // A replacement of a row's word, by word: the later statement's stands. + for (auto const& r : o.replace) { + std::erase_if(b.replace, [&](const mp::Replacement& e) { return e.word == r.word; }); + b.replace.push_back(r); + } for (auto const& [k, v] : o.versions) b.versions[k] = v; if (!o.versions.empty()) b.versionsOrigin = o.versionsOrigin; if (o.hosted.set) { diff --git a/src/pm/lock_io.cppm b/src/pm/lock_io.cppm index 7e486ae9d..1beade3e3 100644 --- a/src/pm/lock_io.cppm +++ b/src/pm/lock_io.cppm @@ -67,9 +67,22 @@ struct LockedGitSource { std::optional parse_git_source(std::string_view source); +// THE IDENTITY A REQUEST WAS GIVEN (SPEC-016 §4.5): `[target.""]` +// with the identity and its provider. A request that names a pattern +// (`x86_64-windows`) may select another identity on another machine; the +// record says which one this project's builds used, and `--locked` refuses a +// build that selects another. Written for every configuration a request +// selected, kept for the others; absent in a lock written before the table. +struct LockedTarget { + std::string request; + std::string identity; + std::string provider; +}; + struct Lockfile { int schemaVersion = 2; std::vector indices; + std::vector targets; std::vector packages; }; @@ -137,6 +150,18 @@ std::expected load(const std::filesystem::path& path) { } } + if (auto* tgt = doc->get_table("target")) { + for (auto& [k, v] : *tgt) { + if (!v.is_table()) continue; + auto& tt = v.as_table(); + LockedTarget lt; + lt.request = k; + if (auto it = tt.find("identity"); it != tt.end() && it->second.is_string()) lt.identity = it->second.as_string(); + if (auto it = tt.find("provider"); it != tt.end() && it->second.is_string()) lt.provider = it->second.as_string(); + if (!lt.identity.empty()) lock.targets.push_back(std::move(lt)); + } + } + // [[package]] arrays are not in our minimal parser. We use [package.] instead. // Or just iterate the root looking for top-level "package" table that contains a list. // For simplicity in M2: accept either format with top-level array of tables described @@ -200,6 +225,12 @@ std::string serialize(const Lockfile& lock) { out += std::format("rev = {}\n", t::escape_string(idx.rev)); } + for (auto& tg : lock.targets) { + out += std::format("\n[target.{}]\n", t::escape_string(tg.request)); + out += std::format("identity = {}\n", t::escape_string(tg.identity)); + if (!tg.provider.empty()) out += std::format("provider = {}\n", t::escape_string(tg.provider)); + } + // Blank line before packages if we had indices or just after version. if (!lock.packages.empty()) out += "\n"; diff --git a/tests/e2e/1042_a_row_default_is_replaced_and_each_flag_names_its_layer.sh b/tests/e2e/1042_a_row_default_is_replaced_and_each_flag_names_its_layer.sh new file mode 100755 index 000000000..8ad5288ef --- /dev/null +++ b/tests/e2e/1042_a_row_default_is_replaced_and_each_flag_names_its_layer.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# requires: elf llvm +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1042 -- the row's defaults are replaced by name, and every flag names its layer (mcpp#911 G2; +# SPEC-016 §4.5, §5.2). +# +# `[target..replace]` names a word the row gives (`-march=`, `-mcmodel=`, its flags, +# `-m `) and the words in its place; an empty list removes it, and a word the row does +# not give is reported with the row's words. `mcpp why flags ` lists each word of the unit's +# command line with the layer that gave it, and `mcpp why target` the request's selection. +# +# Criteria: the compile line has `-march=x86-64-v2` and no `-mcmodel=small`; the build reports each +# replacement and warns about the unmet one; `why flags` attributes the replaced word to mcpp.toml +# and `-mno-red-zone` to the row; `why target --format json` names the request, the identity and +# the candidates in order. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +mkdir -p "$t/src" +cat > "$t/mcpp.toml" <<'EOF' +[package] +name = "bare" +version = "0.1.0" + +[build] +sources = ["src/*.c"] + +[targets.bare] +kind = "bin" +main = "src/main.c" + +[target.x86_64-none-elf.replace] +"-mcmodel=small" = [] +"-march=x86-64" = "-march=x86-64-v2" +"-mno-such" = [] +EOF +cat > "$t/src/main.c" <<'EOF' +void _start(void) { __asm__ volatile("mov $60, %eax\n mov $7, %edi\n syscall"); } +EOF +cd "$t" + +out=$("$MCPP" build --target x86_64-none-elf 2>&1) || fail "the build did not succeed" "$out" +grep -q 'Replaces `-march=x86-64` → `-march=x86-64-v2` (mcpp.toml:' <<<"$out" || fail "the replacement is not reported" "$out" +grep -q 'Replaces `-mcmodel=small` → nothing' <<<"$out" || fail "the removal is not reported" "$out" +grep -q "replaces \`-mno-such\`, which the row x86_64-none-elf does not give" <<<"$out" \ + || fail "a replacement that meets no word is not reported" "$out" +ninja=$(find target/x86_64-none-elf -name build.ninja | head -1) +cflags=$(grep '^cflags' "$ninja") +grep -q -- '-march=x86-64-v2' <<<"$cflags" || fail "the replacement is not on the compile line" "$cflags" +grep -q -- '-mcmodel=small' <<<"$cflags" && fail "the removed word is still on the compile line" "$cflags" +echo " ok the row's words are replaced and removed by name, and an unmet one is reported" + +why=$("$MCPP" why flags main.c --target x86_64-none-elf 2>/dev/null) || fail "why flags failed" "$why" +grep -Eq '^ replace +-march=x86-64-v2 +\[mcpp.toml:[0-9]+, in place of `-march=x86-64`\]' <<<"$why" \ + || fail "the replaced word does not name its statement" "$why" +grep -Eq '^ row +-mno-red-zone +\[x86_64-none-elf\]' <<<"$why" || fail "the row's word does not name the row" "$why" +grep -Eq '^ engine +-ffreestanding' <<<"$why" || fail "the engine's convention does not name the engine" "$why" +echo " ok why flags names the layer of each word" + +json=$("$MCPP" why target --target x86_64-windows --format json 2>/dev/null) || fail "why target failed" "$json" +python3 - "$json" <<'PY' || fail "why target --format json does not state the selection" "$json" +import json, sys +t = json.loads(sys.argv[1])["data"]["target"] +assert t["request"] == "x86_64-windows", t +assert t["identity"] in ("x86_64-windows-msvc", "x86_64-windows-musl", "x86_64-windows-gnu"), t +ids = [c["identity"] for c in t["candidates"]] +assert ids[0] == "x86_64-windows-msvc", ids +assert any(c["identity"] == t["identity"] and c["provider"] == t["provider"] for c in t["candidates"]), t +PY +echo " ok why target states the request, the identity and the candidates in order" +echo "PASS: 1042 a row default is replaced and each flag names its layer" diff --git a/tests/fixtures/target-rows.snapshot b/tests/fixtures/target-rows.snapshot index e7c576c00..afd0ade56 100644 --- a/tests/fixtures/target-rows.snapshot +++ b/tests/fixtures/target-rows.snapshot @@ -36,6 +36,25 @@ row x86_64-linux-musl platform-name linux descriptor-keys linux sysroot +row aarch64-linux-gnu + table tier=preview note= pin=llvm@23.1.3 sysroot= static=0 platform= + str aarch64-linux-gnu + llvm aarch64-unknown-linux-gnu + llvm@15.0 aarch64-unknown-linux-gnu + format ELF + family unix + pe/macho/wasm 000 + apple/ios/sim/android 0000 + freestanding/openkal/own-sysroot/capability 0000 + musl/msvc/windows-gnu/x86_32 0000 + msvc-arch arm64 + nasm - + naming exe= lib=lib static=.a shared=.so import=0 sharedPrefix=- + application exe + android-abi arm64-v8a + platform-name linux + descriptor-keys linux + sysroot row aarch64-linux-musl table tier=verified note= pin=gcc@16.1.0 sysroot= static=1 platform= str aarch64-linux-musl @@ -169,25 +188,6 @@ row riscv64-linux-musl platform-name linux descriptor-keys linux sysroot -row aarch64-linux-gnu - table tier=preview note= pin=llvm@23.1.3 sysroot= static=0 platform= - str aarch64-linux-gnu - llvm aarch64-unknown-linux-gnu - llvm@15.0 aarch64-unknown-linux-gnu - format ELF - family unix - pe/macho/wasm 000 - apple/ios/sim/android 0000 - freestanding/openkal/own-sysroot/capability 0000 - musl/msvc/windows-gnu/x86_32 0000 - msvc-arch arm64 - nasm - - naming exe= lib=lib static=.a shared=.so import=0 sharedPrefix=- - application exe - android-abi arm64-v8a - platform-name linux - descriptor-keys linux - sysroot row x86_64-macos table tier=planned note= pin= sysroot= static=0 platform= str x86_64-macos @@ -704,124 +704,104 @@ row aarch64-openkal sysroot request x86_64-linux parsed x86_64-linux-gnu envExplicit=0 - resolved x86_64-linux-gnu completed=0 ambiguous=0 - siblings x86_64-linux-gnu x86_64-linux-musl - supported x86_64-linux-gnu x86_64-linux-musl - known 1 + on linux-x86_64 -> x86_64-linux-gnu glibc fallback=0 | x86_64-linux-gnu/glibc/100 x86_64-linux-musl/musl-gcc/100 x86_64-linux-musl/openkal/50 + on macos-aarch64 -> x86_64-linux-musl openkal fallback=1 | x86_64-linux-gnu/glibc/100/x x86_64-linux-musl/musl-gcc/100/x x86_64-linux-musl/openkal/50 + on windows-x86_64 -> x86_64-linux-musl musl-gcc fallback=1 | x86_64-linux-gnu/glibc/100/x x86_64-linux-musl/musl-gcc/100 x86_64-linux-musl/openkal/50 request aarch64-linux parsed aarch64-linux-gnu envExplicit=0 - resolved aarch64-linux-gnu completed=0 ambiguous=0 - siblings aarch64-linux-musl aarch64-linux-gnu - supported aarch64-linux-musl aarch64-linux-gnu - known 1 + on linux-x86_64 -> aarch64-linux-musl musl-gcc fallback=1 | aarch64-linux-gnu/glibc/100/x aarch64-linux-musl/musl-gcc/100 aarch64-linux-musl/openkal/50 + on macos-aarch64 -> aarch64-linux-musl openkal fallback=1 | aarch64-linux-gnu/glibc/100/x aarch64-linux-musl/musl-gcc/100/x aarch64-linux-musl/openkal/50 + on windows-x86_64 -> aarch64-linux-musl openkal fallback=1 | aarch64-linux-gnu/glibc/100/x aarch64-linux-musl/musl-gcc/100/x aarch64-linux-musl/openkal/50 request riscv64-linux parsed riscv64-linux-gnu envExplicit=0 - resolved riscv64-linux-gnu completed=0 ambiguous=0 - siblings riscv64-linux-musl - supported - known 0 + on linux-x86_64 -> riscv64-linux-musl musl-gcc fallback=0 | riscv64-linux-musl/musl-gcc/100/x riscv64-linux-musl/openkal/50/x + on macos-aarch64 -> riscv64-linux-musl musl-gcc fallback=0 | riscv64-linux-musl/musl-gcc/100/x riscv64-linux-musl/openkal/50/x + on windows-x86_64 -> riscv64-linux-musl musl-gcc fallback=0 | riscv64-linux-musl/musl-gcc/100/x riscv64-linux-musl/openkal/50/x request x86_64-windows parsed x86_64-windows-gnu envExplicit=0 - resolved x86_64-windows-gnu completed=0 ambiguous=0 - siblings x86_64-windows-gnu x86_64-windows-musl x86_64-windows-msvc - supported x86_64-windows-gnu x86_64-windows-musl x86_64-windows-msvc - known 1 + on linux-x86_64 -> x86_64-windows-musl openkal fallback=1 | x86_64-windows-msvc/msvc/100/x x86_64-windows-musl/openkal/50 x86_64-windows-gnu/mingw/10 + on macos-aarch64 -> x86_64-windows-musl openkal fallback=1 | x86_64-windows-msvc/msvc/100/x x86_64-windows-musl/openkal/50 x86_64-windows-gnu/mingw/10/x + on windows-x86_64 -> x86_64-windows-musl openkal fallback=1 | x86_64-windows-msvc/msvc/100/x x86_64-windows-musl/openkal/50 x86_64-windows-gnu/mingw/10 request i686-windows parsed i686-windows-gnu envExplicit=0 - resolved i686-windows-gnu completed=0 ambiguous=0 - siblings - supported - known 0 + on linux-x86_64 -> - - fallback=0 | + on macos-aarch64 -> - - fallback=0 | + on windows-x86_64 -> - - fallback=0 | request aarch64-windows parsed aarch64-windows-gnu envExplicit=0 - resolved aarch64-windows-gnu completed=0 ambiguous=0 - siblings - supported - known 0 + on linux-x86_64 -> - - fallback=0 | + on macos-aarch64 -> - - fallback=0 | + on windows-x86_64 -> - - fallback=0 | request x86_64-macos parsed x86_64-macos envExplicit=0 - resolved x86_64-macos-musl completed=1 ambiguous=0 - siblings x86_64-macos x86_64-macos-musl - supported x86_64-macos-musl - known 1 + on linux-x86_64 -> x86_64-macos-musl openkal fallback=0 | x86_64-macos-musl/openkal/50 + on macos-aarch64 -> x86_64-macos-musl openkal fallback=0 | x86_64-macos-musl/openkal/50 + on windows-x86_64 -> x86_64-macos-musl openkal fallback=0 | x86_64-macos-musl/openkal/50 request aarch64-macos parsed aarch64-macos envExplicit=0 - resolved aarch64-macos completed=0 ambiguous=0 - siblings aarch64-macos aarch64-macos-musl - supported aarch64-macos aarch64-macos-musl - known 1 + on linux-x86_64 -> aarch64-macos-musl openkal fallback=1 | aarch64-macos/apple/100/x aarch64-macos-musl/openkal/50 + on macos-aarch64 -> aarch64-macos apple fallback=0 | aarch64-macos/apple/100 aarch64-macos-musl/openkal/50 + on windows-x86_64 -> aarch64-macos-musl openkal fallback=1 | aarch64-macos/apple/100/x aarch64-macos-musl/openkal/50 request aarch64-ios parsed aarch64-ios envExplicit=0 - resolved aarch64-ios completed=0 ambiguous=0 - siblings aarch64-ios aarch64-ios-sim - supported aarch64-ios aarch64-ios-sim - known 1 + on linux-x86_64 -> aarch64-ios apple fallback=0 | aarch64-ios/apple/100/x aarch64-ios-sim/apple/100/x + on macos-aarch64 -> aarch64-ios apple fallback=0 | aarch64-ios/apple/100 aarch64-ios-sim/apple/100 + on windows-x86_64 -> aarch64-ios apple fallback=0 | aarch64-ios/apple/100/x aarch64-ios-sim/apple/100/x request x86_64-openkal parsed x86_64-openkal envExplicit=0 - resolved x86_64-openkal completed=0 ambiguous=0 - siblings x86_64-openkal - supported x86_64-openkal - known 1 + on linux-x86_64 -> x86_64-openkal openkal fallback=0 | x86_64-openkal/openkal/100 + on macos-aarch64 -> x86_64-openkal openkal fallback=0 | x86_64-openkal/openkal/100 + on windows-x86_64 -> x86_64-openkal openkal fallback=0 | x86_64-openkal/openkal/100 request x86_64-w64-mingw32 parsed x86_64-windows-gnu envExplicit=1 - resolved x86_64-windows-gnu completed=0 ambiguous=0 - siblings - supported - known 1 + on linux-x86_64 -> x86_64-windows-gnu mingw fallback=0 | x86_64-windows-gnu/mingw/10 + on macos-aarch64 -> x86_64-windows-gnu mingw fallback=0 | x86_64-windows-gnu/mingw/10/x + on windows-x86_64 -> x86_64-windows-gnu mingw fallback=0 | x86_64-windows-gnu/mingw/10 request x86_64-unknown-linux-gnu parsed x86_64-linux-gnu envExplicit=1 - resolved x86_64-linux-gnu completed=0 ambiguous=0 - siblings - supported - known 1 + on linux-x86_64 -> x86_64-linux-gnu glibc fallback=0 | x86_64-linux-gnu/glibc/100 + on macos-aarch64 -> x86_64-linux-gnu glibc fallback=0 | x86_64-linux-gnu/glibc/100/x + on windows-x86_64 -> x86_64-linux-gnu glibc fallback=0 | x86_64-linux-gnu/glibc/100/x request x86_64-pc-windows-msvc parsed x86_64-windows-msvc envExplicit=1 - resolved x86_64-windows-msvc completed=0 ambiguous=0 - siblings - supported - known 1 + on linux-x86_64 -> x86_64-windows-msvc msvc fallback=0 | x86_64-windows-msvc/msvc/100/x + on macos-aarch64 -> x86_64-windows-msvc msvc fallback=0 | x86_64-windows-msvc/msvc/100/x + on windows-x86_64 -> x86_64-windows-msvc msvc fallback=0 | x86_64-windows-msvc/msvc/100/x request arm64-apple-darwin24.1.0 parsed aarch64-macos envExplicit=0 - resolved aarch64-macos completed=0 ambiguous=0 - siblings aarch64-macos aarch64-macos-musl - supported aarch64-macos aarch64-macos-musl - known 1 + on linux-x86_64 -> aarch64-macos-musl openkal fallback=1 | aarch64-macos/apple/100/x aarch64-macos-musl/openkal/50 + on macos-aarch64 -> aarch64-macos apple fallback=0 | aarch64-macos/apple/100 aarch64-macos-musl/openkal/50 + on windows-x86_64 -> aarch64-macos-musl openkal fallback=1 | aarch64-macos/apple/100/x aarch64-macos-musl/openkal/50 request arm64-apple-macosx15.0 parsed aarch64-macos envExplicit=0 - resolved aarch64-macos completed=0 ambiguous=0 - siblings aarch64-macos aarch64-macos-musl - supported aarch64-macos aarch64-macos-musl - known 1 + on linux-x86_64 -> aarch64-macos-musl openkal fallback=1 | aarch64-macos/apple/100/x aarch64-macos-musl/openkal/50 + on macos-aarch64 -> aarch64-macos apple fallback=0 | aarch64-macos/apple/100 aarch64-macos-musl/openkal/50 + on windows-x86_64 -> aarch64-macos-musl openkal fallback=1 | aarch64-macos/apple/100/x aarch64-macos-musl/openkal/50 request aarch64-unknown-linux-musl parsed aarch64-linux-musl envExplicit=1 - resolved aarch64-linux-musl completed=0 ambiguous=0 - siblings - supported - known 1 + on linux-x86_64 -> aarch64-linux-musl musl-gcc fallback=0 | aarch64-linux-musl/musl-gcc/100 aarch64-linux-musl/openkal/50 + on macos-aarch64 -> aarch64-linux-musl openkal fallback=1 | aarch64-linux-musl/musl-gcc/100/x aarch64-linux-musl/openkal/50 + on windows-x86_64 -> aarch64-linux-musl openkal fallback=1 | aarch64-linux-musl/musl-gcc/100/x aarch64-linux-musl/openkal/50 request aarch64-linux-android21 parsed aarch64-linux-android envExplicit=1 - resolved aarch64-linux-android completed=0 ambiguous=0 - siblings - supported - known 1 + on linux-x86_64 -> aarch64-linux-android android-ndk fallback=0 | aarch64-linux-android/android-ndk/100 + on macos-aarch64 -> aarch64-linux-android android-ndk fallback=0 | aarch64-linux-android/android-ndk/100 + on windows-x86_64 -> aarch64-linux-android android-ndk fallback=0 | aarch64-linux-android/android-ndk/100 request wasm32-unknown-emscripten parsed wasm32-emscripten envExplicit=0 - resolved wasm32-emscripten completed=0 ambiguous=0 - siblings wasm32-emscripten - supported wasm32-emscripten - known 1 + on linux-x86_64 -> wasm32-emscripten emsdk fallback=0 | wasm32-emscripten/emsdk/100 + on macos-aarch64 -> wasm32-emscripten emsdk fallback=0 | wasm32-emscripten/emsdk/100 + on windows-x86_64 -> wasm32-emscripten emsdk fallback=0 | wasm32-emscripten/emsdk/100 request x86-windows-msvc parsed i686-windows-msvc envExplicit=1 - resolved i686-windows-msvc completed=0 ambiguous=0 - siblings - supported - known 0 + on linux-x86_64 -> - - fallback=0 | + on macos-aarch64 -> - - fallback=0 | + on windows-x86_64 -> - - fallback=0 | request i686-none-elf parsed i686-none-elf envExplicit=1 - resolved i686-none-elf completed=0 ambiguous=0 - siblings - supported - known 1 + on linux-x86_64 -> i686-none-elf bare-metal fallback=0 | i686-none-elf/bare-metal/100 + on macos-aarch64 -> i686-none-elf bare-metal fallback=0 | i686-none-elf/bare-metal/100 + on windows-x86_64 -> i686-none-elf bare-metal fallback=0 | i686-none-elf/bare-metal/100 request x86_64-freebsd unparseable request x86_64-linux-gnux32 diff --git a/tests/unit/test_freestanding.cpp b/tests/unit/test_freestanding.cpp index 54198337a..97d4fcfd6 100644 --- a/tests/unit/test_freestanding.cpp +++ b/tests/unit/test_freestanding.cpp @@ -6,6 +6,7 @@ import mcpp.freestanding.target; import mcpp.freestanding.linkline; import mcpp.freestanding.runner; import mcpp.toolchain.triple; +import mcpp.toolchain.target_schema; import mcpp.provider.xlings; import mcpp.build.build_program; @@ -714,3 +715,24 @@ UTest arm32_soft_and_hard_variants_name_different_libdirs{"FreestandingTarget/Ar expect(gt(pairs, 0)) << "no architecture carries both float ABIs; the check " "above quantified over nothing"; }}; + +// THE ROW'S WORDS ARE REPLACED, THE ENGINE'S CONVENTIONS ARE NOT (SPEC-016 §5.2). +UTest a_replacement_changes_the_rows_words_only{"FreestandingTarget/AReplacementChangesTheRowsWordsOnly", [] { + auto s = resolve("x86_64-none-elf"); + expect(fatal(s.has_value())); + const std::vector r{ + { "-mcmodel=small", {}, "mcpp.toml:3" }, + { "-march=x86-64", { "-march=i686" }, "mcpp.toml:4" }, + { "-ffreestanding", {}, "mcpp.toml:5" }, // a convention: not the row's + { "-m elf_x86_64", { "-m elf_i386" }, "mcpp.toml:6" }, + }; + auto f = compile_flags(*s, false, false, r); + expect(std::ranges::find(f, std::string("-mcmodel=small")) == f.end()); + expect(std::ranges::find(f, std::string("-march=i686")) != f.end()); + expect(std::ranges::find(f, std::string("-mno-red-zone")) != f.end()); + expect(std::ranges::find(f, std::string("-ffreestanding")) != f.end()); + const auto esc = [](const std::filesystem::path& p) { return p.generic_string(); }; + const auto direct = mcpp::freestanding::link_flags_direct(*s, {}, esc, r); + expect(ne(direct.find(" -m elf_i386 "), std::string::npos)) << direct; + expect(eq(direct.find("elf_x86_64"), std::string::npos)) << direct; +}}; diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index d8da0c35e..745944648 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -486,7 +486,7 @@ package = { ["1.0.0"] = { url = "u3", sha256 = "z" }, }, macosx = { - ["0.1.0"] = { url = "u1m", sha256 = "x" }, + ["0.1.0"] = { url = "u1m", sha256 = "xm" }, }, }, } @@ -501,10 +501,37 @@ package = { expect(fatal(eq(mac.size(), 1u))); expect(eq(mac[0], "0.1.0")); + // No block of its own, and no artifact the same in every block: nothing. auto win = mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("windows")); expect(win.empty()); }}; +// THE ARTIFACT THAT IS ONE FOR EVERY TARGET (mcpp#911 G6): a target whose block +// the descriptor lacks takes the versions whose artifact is the same in every +// block it has -- a source archive -- and not those built per system. +UTest an_artifact_the_same_in_every_block_serves_every_target{"ListXpkgVersions/AnArtifactTheSameInEveryBlockServesEveryTarget", [] { + constexpr auto src = R"( +package = { + name = "foo", + xpm = { + linux = { + ["1.0.0"] = { url = "src.tar.gz", sha256 = "s" }, + ["1.1.0"] = { url = "linux.tar.gz", sha256 = "l" }, + }, + macosx = { + ["1.0.0"] = { url = "src.tar.gz", sha256 = "s" }, + ["1.1.0"] = { url = "macos.tar.gz", sha256 = "m" }, + }, + }, +} +)"; + auto any = mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::with_keys({"toyos"})); + expect(fatal(eq(any.size(), 1u))); + expect(eq(any[0], "1.0.0")); + auto linux = mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")); + expect(eq(linux.size(), 2u)); +}}; + UTest missing_xpm_returns_empty{"ListXpkgVersions/MissingXpmReturnsEmpty", [] { constexpr auto src = R"(package = { name = "foo" })"; expect(mcpp::manifest::list_xpkg_versions(src, mcpp::toolchain::platform::TargetPlatform::for_lint_of("linux")).empty()); diff --git a/tests/unit/test_platform.cpp b/tests/unit/test_platform.cpp index 3960ec22a..b451857a2 100644 --- a/tests/unit/test_platform.cpp +++ b/tests/unit/test_platform.cpp @@ -523,7 +523,9 @@ UTest a_carrier_without_hosts_serves_every_pair_and_none_serves_nothing{"Platfor expect(eq(mp::carrier_for(none, "linux-x86_64"), nullptr)); }}; -UTest host_pairs_and_descriptor_keys_are_a_closed_vocabulary{"PlatformManifest/HostPairsAndDescriptorKeysAreAClosedVocabulary", [] { +// Host pairs are this mcpp's; a descriptor key is any word, since a platform's +// rows may read blocks of their own (SPEC-016 §7). +UTest host_pairs_are_closed_and_descriptor_keys_are_words{"PlatformManifest/HostPairsAreClosedAndDescriptorKeysAreWords", [] { const auto base = std::string(R"( [package] name = "p" @@ -533,7 +535,8 @@ targets = ["x86_64-openkal"] )"); expect(ne(parse_error(base + R"(runner = { cmd = ["okrun"], hosts = ["linux"] })" "\n"), "")); expect(ne(parse_error(base + R"(runner = { cmd = ["okrun"], hosts = ["freebsd-x86_64"] })" "\n"), "")); - expect(ne(parse_error(base + R"(descriptor_keys = ["macos"])" "\n").find("macosx"), std::string::npos)); + expect(ne(parse_error(base + R"(descriptor_keys = ["mac os"])" "\n").find("macosx"), std::string::npos)); + expect(eq(parse_error(base + R"(descriptor_keys = ["freebsd", "linux"])" "\n"), "")); expect(ne(parse_error(base + R"(descriptor_keys = [])" "\n"), "")); expect(eq(parse_error(base + R"(descriptor_keys = ["openkal", "linux"])" "\n"), "")); }}; @@ -574,3 +577,37 @@ UTest a_newer_release_is_named_only_for_the_default{"PlatformDefault/ANewerRelea expect(!mcpp::platform::newer_release_tip(builtIn, "openkal-platform", "0.24.3", "0.24.3", "x")); expect(!mcpp::platform::newer_release_tip(builtIn, "openkal-platform", "0.24.3", "", "x")); }}; + +// THE ROW'S DEFAULTS ARE REPLACED BY NAME (SPEC-016 §5.2): `[platform.replace]` +// and `[target..replace]` read a word, a list or nothing, and a later +// statement's replacement of the same word stands. +UTest a_replacement_names_a_row_word{"PlatformManifest/AReplacementNamesARowWord", [] { + auto m = parse(R"( +[package] +name = "p" +version = "1.0.0" + +[platform] +targets = ["x86_64-none-elf"] +[platform.replace] +"-mcmodel=small" = [] +"-march=x86-64" = "-march=i686" + +[target.x86_64-none-elf.replace] +"-march=x86-64" = ["-march=pentium4", "-mtune=generic"] +)"); + for (auto const& w : m.schemaWarnings) expect(false) << "unexpected warning: " << w; + auto const& r = m.platform.replace; + expect(fatal(eq(r.size(), 2u))); + auto removed = std::ranges::find(r, std::string("-mcmodel=small"), &mp::Replacement::word); + expect(fatal(removed != r.end())); + expect(removed->with.empty()); + expect(fatal(eq(m.platformOverrides.size(), 1u))); + auto merged = m.platform; + pf::merge(merged, m.platformOverrides[0].fields); + auto march = std::ranges::find(merged.replace, std::string("-march=x86-64"), &mp::Replacement::word); + expect(fatal(march != merged.replace.end())); + expect(eq(march->with, (std::vector{"-march=pentium4", "-mtune=generic"}))); + expect(eq(march->origin.tier, mp::Tier::Manifest)); + expect(eq(merged.replace.size(), 2u)); +}}; diff --git a/tests/unit/test_pm_lock_io.cpp b/tests/unit/test_pm_lock_io.cpp index e24cea462..ad3c1db59 100644 --- a/tests/unit/test_pm_lock_io.cpp +++ b/tests/unit/test_pm_lock_io.cpp @@ -239,3 +239,36 @@ UTest targets_round_trip{"PmLockIo/TargetsRoundTrip", [] { std::error_code ec; std::filesystem::remove_all(dir, ec); }}; + +// THE IDENTITY A REQUEST WAS GIVEN IS RECORDED (SPEC-016 §4.5): written as +// `[target.""]` and read back as it was written. +UTest a_requests_identity_round_trips{"PmLockIo/ARequestsIdentityRoundTrips", [] { + mcpp::pm::Lockfile lock; + lock.targets.push_back({ "x86_64-windows", "x86_64-windows-musl", "openkal" }); + lock.targets.push_back({ "x86_64-linux", "x86_64-linux-gnu", "glibc" }); + mcpp::pm::LockedPackage p; + p.name = "mcpplibs.cmdline"; + p.namespace_ = "mcpplibs"; + p.version = "0.0.1"; + p.source = "index+mcpplibs@0.0.1"; + p.hash = "fnv1a:0"; + lock.packages.push_back(p); + const auto text = mcpp::pm::serialize(lock); + expect(ne(text.find("[target.\"x86_64-windows\"]\nidentity = \"x86_64-windows-musl\"\nprovider = \"openkal\""), + std::string::npos)) << text; + const auto dir = std::filesystem::temp_directory_path() + / std::format("mcpp-lock-target-{}", std::random_device{}()); + std::filesystem::create_directories(dir); + { + std::ofstream(dir / "mcpp.lock", std::ios::binary) << text; + } + auto back = mcpp::pm::load(dir / "mcpp.lock"); + std::filesystem::remove_all(dir); + expect(fatal(back.has_value())); + expect(fatal(eq(back->targets.size(), 2u))); + auto w = std::ranges::find(back->targets, std::string("x86_64-windows"), &mcpp::pm::LockedTarget::request); + expect(fatal(w != back->targets.end())); + expect(eq(w->identity, "x86_64-windows-musl")); + expect(eq(w->provider, "openkal")); + expect(eq(back->packages.size(), 1u)); +}}; diff --git a/tests/unit/test_target_rows_snapshot.cpp b/tests/unit/test_target_rows_snapshot.cpp index c72366289..82c40e7ba 100644 --- a/tests/unit/test_target_rows_snapshot.cpp +++ b/tests/unit/test_target_rows_snapshot.cpp @@ -2,6 +2,8 @@ import std; import mcpplibs.mtest; import mcpp.toolchain.triple; +import mcpp.toolchain.target_schema; +import mcpp.toolchain.target_select; import mcpp.toolchain.platform; import mcpp.freestanding.target; import mcpp.freestanding.linkline; @@ -95,14 +97,22 @@ std::string render() { o += std::format("request {}\n", r); auto t = tr::parse(r); if (!t) { o += " unparseable\n"; continue; } - auto res = tr::resolve_request(*t); - std::vector sib(res.siblings.begin(), res.siblings.end()); - std::vector sup(res.supported.begin(), res.supported.end()); o += std::format(" parsed {} envExplicit={}\n", t->str(), yes(t->envExplicit)); - o += std::format(" resolved {} completed={} ambiguous={}\n", res.triple.str(), - yes(res.completedFromVocabulary), yes(res.ambiguous)); - o += std::format(" siblings {}\n supported {}\n", join(sib), join(sup)); - o += std::format(" known {}\n", yes(tr::is_known_target(res.triple))); + // The selection (SPEC-016 §4) on three hosts, none with MSVC. + for (auto host : { "linux-x86_64", "macos-aarch64", "windows-x86_64" }) { + mcpp::toolchain::target::Context ctx; + ctx.host = host; + auto sel = mcpp::toolchain::target::select(r, t->arch, t->os, t->env, t->envExplicit, ctx); + std::vector cand; + for (auto const& c : sel.candidates) + cand.push_back(std::format("{}/{}/{}{}", c.row->triple, + c.provider ? c.provider->name : std::string_view("-"), + c.priority, c.available ? "" : "/x")); + o += std::format(" on {} -> {} {} fallback={} | {}\n", host, + sel.row ? sel.identity() : std::string("-"), + sel.provider ? sel.provider->name : std::string_view("-"), + yes(sel.fallback), join(cand)); + } } static constexpr std::string_view kTypos[] = { "x86_64-linux-mus", "x86-64-linux-gnu", "aarch64-macso", "riscv64-none-eld", "thumbv7m-none", diff --git a/tests/unit/test_toolchain_triple.cpp b/tests/unit/test_toolchain_triple.cpp index ded0c22f1..aa3085868 100644 --- a/tests/unit/test_toolchain_triple.cpp +++ b/tests/unit/test_toolchain_triple.cpp @@ -70,112 +70,14 @@ UTest normalizes_bare_linux_to_gnu_env{"Triple/NormalizesBareLinuxToGnuEnv", [] expect(eq(parse("x86_64-linux")->str(), "x86_64-linux-gnu")); }}; -// ── resolve_request: the REQUEST is completed from the vocabulary ──────────── -// -// `parse` fills lexically so the IDENTITY stays total and host-independent (the -// test just above pins that). `resolve_request` answers the other half — which -// row a request that named no C library should resolve to — and it is the one -// that consults `kKnownTargets`. - -UTest a_supported_lexical_default_is_kept{"TripleRequest/ASupportedLexicalDefaultIsKept", [] { - // x86_64-linux-gnu is `verified`, so nothing moves. This is the control for - // the case below: a fix that sent every bare `-linux` to musl would pass the - // aarch64 test and break every project on the planet. - auto r = triple::resolve_request(*parse("x86_64-linux")); - expect(eq(r.triple.str(), "x86_64-linux-gnu")); - expect(!r.completedFromVocabulary); - expect(!r.ambiguous); -}}; - -UTest native_arm64_uses_the_supported_gnu_row{"TripleRequest/NativeArm64UsesTheSupportedGnuRow", [] { - // The supported GNU lexical default outranks the musl sibling. - auto r = triple::resolve_request(*parse("aarch64-linux")); - expect(eq(r.triple.str(), "aarch64-linux-gnu")); - expect(!r.completedFromVocabulary); - // mcpp CHOOSING A ROW IS NOT THE PROJECT NAMING A C LIBRARY. `envExplicit` - // feeds the request/fact comparison and the report's display name; setting - // it here would make mcpp compare its own answer against itself. - expect(!r.triple.envExplicit); -}}; - -UTest a_bare_linux_triple_is_never_completed_to_android{"TripleRequest/ABareLinuxTripleIsNeverCompletedToAndroid", [] { - // `aarch64-linux` asks for a C LIBRARY to be filled in. `android` shares - // the `arch-os` prefix because its kernel is Linux -- the modelling - // decision that makes every Linux-shaped answer in the tree right about it - // -- and it is not an alternative C library for the same platform. - // - // This became reachable when the Android rows stopped being `planned`: - // `aarch64-linux` then had two supported siblings and resolved as - // ambiguous, where it had completed to `aarch64-linux-musl` before. Both - // outcomes of that ambiguity are wrong -- refusing a request with an - // obvious answer, or answering it with bionic. - auto r = triple::resolve_request(*parse("aarch64-linux")); - expect(eq(r.triple.str(), "aarch64-linux-gnu")); - expect(!r.completedFromVocabulary); - expect(!r.ambiguous); - // Not offered as a suggestion either: `siblings` is what the diagnostic - // prints, and naming it there would suggest building for another platform. - for (auto& s : r.siblings) - expect(eq(s.find("android"), std::string::npos)) << s; - - // x86_64 has the same shape and a different supported set, so it exercises - // the exclusion independently rather than re-testing one row. - auto x = triple::resolve_request(*parse("x86_64-linux")); - for (auto& s : x.siblings) - expect(eq(s.find("android"), std::string::npos)) << s; - - // AND THE WRITTEN SPELLING IS STILL HONOURED. The exclusion is about - // filling a gap, not about refusing a request -- an explicit env returns - // before the candidate loop runs. - auto explicitAndroid = triple::resolve_request(*parse("aarch64-linux-android")); - expect(eq(explicitAndroid.triple.str(), "aarch64-linux-android")); - expect(!explicitAndroid.completedFromVocabulary); -}}; - -UTest a_written_segment_is_a_request_and_is_not_revised{"TripleRequest/AWrittenSegmentIsARequestAndIsNotRevised", [] { - // An explicit environment segment is preserved independently of tier. - auto r = triple::resolve_request(*parse("aarch64-linux-gnu")); - expect(eq(r.triple.str(), "aarch64-linux-gnu")); - expect(!r.completedFromVocabulary); -}}; - -UTest a_family_with_no_supported_row_keeps_the_lexical_fill_and_reports_its_rows{"TripleRequest/AFamilyWithNoSupportedRowKeepsTheLexicalFillAndReportsItsRows", [] { - // riscv64-linux-musl is `planned` and riscv64-linux-gnu does not exist at - // all, so the fill named a row outside the vocabulary and the refusal came - // out as `unknown target 'riscv64-linux'` — false, the family is registered. - auto r = triple::resolve_request(*parse("riscv64-linux")); - expect(eq(r.triple.str(), "riscv64-linux-gnu")); - expect(!r.completedFromVocabulary); - expect(fatal(eq(r.siblings.size(), 1u))); - expect(eq(r.siblings[0], "riscv64-linux-musl")); - expect(r.supported.empty()); -}}; - -UTest macos_carries_no_env_and_is_left_alone{"TripleRequest/MacosCarriesNoEnvAndIsLeftAlone", [] { - expect(eq(triple::resolve_request(*parse("aarch64-macos")).triple.str(), "aarch64-macos")); - // x86_64-macos is `planned`; the musl identity beside it is the one supported row. - auto r = triple::resolve_request(*parse("x86_64-macos")); - expect(eq(r.triple.str(), "x86_64-macos-musl")); - expect(eq(r.supported.size(), 1u)); -}}; - -UTest windows_and_bare_metal_defaults_are_supported_rows{"TripleRequest/WindowsAndBareMetalDefaultsAreSupportedRows", [] { - // Both lexical fills (`gnu` on Windows, `elf` freestanding) name supported - // rows, so completion is a no-op — recorded so that a future row change - // which breaks that shows up here rather than in a user's build. - expect(eq(triple::resolve_request(*parse("x86_64-windows")).triple.str(), "x86_64-windows-gnu")); - expect(eq(triple::resolve_request(*parse("riscv64-none")).triple.str(), "riscv64-none-elf")); -}}; - -UTest every_supported_row_is_reachable_from_its_own_spelling{"TripleRequest/EverySupportedRowIsReachableFromItsOwnSpelling", [] { - // The completion must never turn a written, supported triple into a - // different one. Checked across the whole vocabulary rather than by example, - // because the failure mode is one row nobody thought to test. +UTest every_supported_row_is_its_own_spelling{"Triple/EverySupportedRowIsItsOwnSpelling", [] { + // A written, supported triple parses to itself: checked across the whole + // vocabulary, because the failure mode is one row nobody thought to test. for (auto const& row : triple::known_targets()) { if (row.tier == "planned") continue; auto t = parse(row.canonical); expect(fatal(t.has_value())) << row.canonical; - expect(eq(triple::resolve_request(*t).triple.str(), std::string(row.canonical))); + expect(eq(t->str(), std::string(row.canonical))); } }}; From dd9ceb776e6560f3bf369c2638a99d4011e2072a Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 08:03:46 +0900 Subject: [PATCH 12/45] pack: generated manifests quote target names; the platform's artifact names stand; several programs are packed as stated (mcpp#911 B1, B2, B3) - A manifest `mcpp pack` generates writes `[targets.""]`. - A platform's `link.exe`/`link.shared` suffix stands over a later statement (a build program's `artifact-name`); the conflict is reported at plan time. - `mcpp pack` with several programs packs the one named after the package and names the others, refuses when none is so named, and `--all-programs` ships them together. A single file replaces an earlier pack's staged tree of the same name. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- src/build/prepare/platform.cpp | 37 +++++++++++- src/build/prepare/state.cppm | 3 +- src/cli.cppm | 2 + src/cli/cmd_publish.cppm | 1 + src/pack/distribution.cppm | 15 +++-- src/pack/manifest_emit.cppm | 3 +- src/pack/pack.cppm | 26 +++++++- src/pack/pipeline.cppm | 40 ++++++++++--- src/pack/route.cppm | 21 +++++-- tests/e2e/1040_a_platform_defines_a_target.sh | 15 +++++ ..._and_a_dotted_name_are_packed_as_stated.sh | 60 +++++++++++++++++++ 11 files changed, 199 insertions(+), 24 deletions(-) create mode 100755 tests/e2e/1043_several_programs_and_a_dotted_name_are_packed_as_stated.sh diff --git a/src/build/prepare/platform.cpp b/src/build/prepare/platform.cpp index 2ab180aac..20d94d976 100644 --- a/src/build/prepare/platform.cpp +++ b/src/build/prepare/platform.cpp @@ -35,6 +35,7 @@ import mcpp.toolchain.target_rows; import mcpp.toolchain.target_select; import mcpp.toolchain.builtin_targets; import mcpp.ui; +import mcpp.diag; import mcpp.version_req; import mcpp.pm.resolver; import mcpp.xpkg_version; @@ -888,9 +889,38 @@ std::vector split_words(std::string_view v) { } // namespace +// THE PLATFORM'S NAMES OF ITS ARTIFACTS STAND (mcpp#911 B2): what follows +// `{name}` in `link.exe` and `link.shared` is the platform's format (`.oke`, +// `.okso`), which its own steps read. A later statement that would change it +// (a build program's `artifact-name`, written for the host's suffix) is +// reported, and the platform's pattern is kept. +namespace { +void keep_platform_naming(mp::Statement& s, const mp::Statement& platform, std::string_view display, + std::vector* conflicts) { + auto suffix = [](std::string_view pattern) { + const auto at = pattern.find("{name}"); + return at == std::string_view::npos ? pattern : pattern.substr(at + 6); + }; + for (auto [field, base, kind] : { std::tuple{ &s.link.exe, &platform.link.exe, "exe" }, + std::tuple{ &s.link.shared, &platform.link.shared, "shared" } }) { + if (!base->set || base->origin.tier != mp::Tier::Platform || suffix(base->value).empty()) continue; + if (!field->set || field->origin.tier == mp::Tier::Platform || suffix(field->value) == suffix(base->value)) + continue; + if (conflicts) + conflicts->push_back(std::format( + "{} names the {} `{}`, and the platform {} names it `{}`: the platform's name stands, " + "since its own steps read it", mcpp::platform::origin_text(field->origin) == "build-program" + ? std::string("a build program") : mcpp::platform::origin_text(field->origin), + kind == std::string_view("exe") ? "program" : "shared library", field->value, display, + base->value)); + *field = *base; + } +} +} // namespace + // ── The statement every reader uses: platform → build programs → this // machine → the project → the environment (§4.5) ────────────────────────── -mp::Statement platform_statement(const PrepareState& state) { +mp::Statement platform_statement(const PrepareState& state, std::vector* conflicts) { auto const& P = state.platform; const auto t = target_of(state); const auto cfgCtx = platform_cfg_context(state); @@ -922,6 +952,7 @@ mp::Statement platform_statement(const PrepareState& state) { for (auto const* o : mcpp::platform::matching(config, matcher(cfgCtx))) mcpp::platform::merge(s, o->fields); auto project = project_tables(*state.m); for (auto const* o : mcpp::platform::matching(project, matcher(cfgCtx))) mcpp::platform::merge(s, o->fields); + if (P.active) keep_platform_naming(s, state.platformData, P.display(), conflicts); const auto envKey = target_env_name(t, "RUNNER"); if (auto e = mcpp::invocation::get(envKey); e && !e->empty()) { if (*e == "none") { @@ -937,7 +968,9 @@ mp::Statement platform_statement(const PrepareState& state) { void step13_platform_statement(PrepareState& state) { auto& P = state.platform; - P.fields = platform_statement(state); + std::vector conflicts; + P.fields = platform_statement(state, &conflicts); + for (auto const& c : conflicts) mcpp::diag::warning("platform/artifact-name", c); P.flags = mcpp::platform::evaluate_flags(P.fields, matcher(platform_cfg_context(state))); } diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index 3095c21cd..0e9a0e5ec 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -1260,7 +1260,8 @@ void step5_platform_toolchain(PrepareState& state); void step13_platform_statement(PrepareState& state); // The platform's statement and the compile words it states for a unit of C++, // as they stand now (the std module's are asked for before step 13). -mcpp::manifest::platform::Statement platform_statement(const PrepareState& state); +mcpp::manifest::platform::Statement platform_statement(const PrepareState& state, + std::vector* conflicts = nullptr); std::vector platform_unit_cxxflags(const PrepareState& state); std::expected step13_finish_platform(PrepareState& state, BuildContext& ctx); // retarget.cpp: a retargetable driver told its target, sysroot and multilib. diff --git a/src/cli.cppm b/src/cli.cppm index f74547821..7f2e81f6a 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -1070,6 +1070,8 @@ int run(int argc, char** argv) { .help("Output format: human (default) | json (one mcpp.pack envelope on stdout; narration on stderr)")) .option(cl::Option("no-strip") .help("Ship the artifacts as built (default: strip debug info)")) + .option(cl::Option("all-programs") + .help("Ship every program of the package: the one named after it (or the named one) and the others beside it")) .option(cl::Option("package").short_name('p').takes_value().multiple().value_name("NAME") .help("Pack the named workspace member (namespace.name or package name, then directory), as if run in its directory; repeat to pack several, planned and built once")) .option(cl::Option("workspace") diff --git a/src/cli/cmd_publish.cppm b/src/cli/cmd_publish.cppm index 4fe754239..28e378f73 100644 --- a/src/cli/cmd_publish.cppm +++ b/src/cli/cmd_publish.cppm @@ -874,6 +874,7 @@ int cmd_pack_body(const mcpplibs::cmdline::ParsedArgs& parsed, parsed.value("profile").value_or(""), parsed.is_flag_set("release"), parsed.is_flag_set("dev")); if (parsed.is_flag_set("no-strip")) opts.strip = false; + if (parsed.is_flag_set("all-programs")) opts.allPrograms = true; if (auto v = parsed.value("debug-symbols")) opts.debugSymbols = *v; if (auto v = parsed.value("features")) opts.features = *v; diff --git a/src/pack/distribution.cppm b/src/pack/distribution.cppm index e620f8d5e..31c4e1386 100644 --- a/src/pack/distribution.cppm +++ b/src/pack/distribution.cppm @@ -17,6 +17,7 @@ import mcpp.pack.compress; import mcpp.pack.zip; import mcpp.pack.polyglot; // a polyglot file is refused by name (§8) import mcpp.toolchain.triple; // the system of a package's leg (§8) +import mcpp.libs.toml; // a generated manifest quotes its keys (mcpp#911 B1) import mcpplibs.os; export namespace mcpp::pack::distribution { @@ -1073,11 +1074,12 @@ std::string exe_launcher_manifest(std::string_view name, std::string_view versio return std::format( "# Generated by `mcpp pack`: the Windows launcher of {0} (design 2026-10-29 §4.3).\n" "[package]\nname = \"{0}\"\nversion = \"{1}\"\n\n" - "[targets.{0}]\nkind = \"bin\"\nmain = \"src/main.cpp\"\n\n" + "[targets.{2}]\nkind = \"bin\"\nmain = \"src/main.cpp\"\n\n" "# The OS library as one release, the one this mcpp was qualified with, so a\n" "# pack is the same at any later time (design 2026-11-07, D20).\n" - "[dependencies]\nmcpplibs.os = \"0.4.0\"\n", - name, version.empty() ? std::string_view("0.0.0") : version); + "[dependencies]\nmcpplibs.os = \"0.4.1\"\n", + name, version.empty() ? std::string_view("0.0.0") : version, + mcpp::libs::toml::escape_string(name)); } std::string exe_launcher_main(const Launch& l) { @@ -1420,10 +1422,11 @@ std::string launcher_manifest(std::string_view name, std::string_view version) { "# Generated by `mcpp pack --pkg-format single-file`: the launcher of {0}, which\n" "# carries the package as a zstd frame and runs it from the user's cache.\n" "[package]\nname = \"{0}\"\nversion = \"{1}\"\n\n" - "[targets.{0}]\nkind = \"bin\"\nmain = \"src/main.cpp\"\n\n" + "[targets.{2}]\nkind = \"bin\"\nmain = \"src/main.cpp\"\n\n" "[build]\nsources = [\"src/main.cpp\", \"src/carried.S\"]\n\n" - "[dependencies]\nmcpplibs.os = \"0.4.0\"\nmcpplibs.mwrap.zstd = \"1.5.7\"\n", - name, version.empty() ? std::string_view("0.0.0") : version); + "[dependencies]\nmcpplibs.os = \"0.4.1\"\nmcpplibs.mwrap.zstd = \"1.5.7\"\n", + name, version.empty() ? std::string_view("0.0.0") : version, + mcpp::libs::toml::escape_string(name)); } std::string_view launcher_main() { diff --git a/src/pack/manifest_emit.cppm b/src/pack/manifest_emit.cppm index 0e747d9a5..ed097cb0e 100644 --- a/src/pack/manifest_emit.cppm +++ b/src/pack/manifest_emit.cppm @@ -38,6 +38,7 @@ export module mcpp.pack.manifest_emit; import std; +import mcpp.libs.toml; // a generated manifest quotes its keys (mcpp#911 B1) export namespace mcpp::pack { @@ -194,7 +195,7 @@ std::string emit_package_manifest(const PackageDoc& doc) { o += std::format("exports = [{}]\n\n", join_quoted(doc.exportsModules)); } - o += std::format("[targets.{}]\n", doc.targetName); + o += std::format("[targets.{}]\n", mcpp::libs::toml::escape_string(doc.targetName)); o += std::format("kind = \"{}\"\n\n", doc.targetShared ? "shared" : "lib"); // ── how to link each leg ─────────────────────────────────────────── diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index 712b14351..c86a54286 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -59,6 +59,7 @@ import mcpp.run; import mcpp.provider; import mcpp.provider.select; import mcpp.manifest; +import mcpp.libs.toml; // a generated manifest quotes its keys (mcpp#911 B1) import mcpp.project; // the effective manifest a build reads import mcpp.build.prepare_inputs; // cfgpred: the target's conditional tables import mcpp.toolchain.triple; @@ -250,6 +251,9 @@ struct Options { // the value `mcpp build --features` takes, carried as that string so the // parser that reads it stays the build's own (#641). std::string features; + // `--all-programs` (mcpp#911 B3): every program of the package, the + // chosen one and the others beside it. + bool allPrograms = false; }; // The strip decision for this run: `--strip`/`--no-strip` > `[pack] strip` > @@ -653,6 +657,23 @@ std::optional parse_mode(std::string_view s) { namespace detail { +// A single file is written where an earlier pack of another format staged its +// tree under the same name (`--`, beside its +// `.stage-manifest`): that tree is this command's own output and is replaced. +// Any other directory there is refused rather than removed. +inline std::string clear_staged_tree(const std::filesystem::path& out) { + std::error_code ec; + if (!std::filesystem::is_directory(out, ec)) return {}; + const auto manifest = mcpp::pack::stage_manifest_path(out); + if (!std::filesystem::exists(manifest, ec)) + return std::format("cannot write {}: a directory of that name is there, and no pack staged it", + out.string()); + std::filesystem::remove_all(out, ec); + if (!ec) std::filesystem::remove(manifest, ec); + return ec ? std::format("cannot replace the staged tree {}: {}", out.string(), ec.message()) + : std::string(); +} + // Helpers below are kept in a NAMED (non-exported) sub-namespace rather // than an anonymous one. Anonymous namespaces inside a module become // TU-local; types declared there can't appear in the signature of any @@ -2564,7 +2585,8 @@ carrier_build(const Plan& plan, const CarrierBuild& b) plan.binaryName, b.system, b.arch, b.carried.empty() ? "runs the image it is given" : "carries it and runs it", plan.platform.display(), b.stem, - plan.packageVersion.empty() ? "0.0.0" : plan.packageVersion, b.stem, + plan.packageVersion.empty() ? "0.0.0" : plan.packageVersion, + mcpp::libs::toml::escape_string(b.stem), dependency_entry(c.package), static_row_pin(row, plan.platform.fields.toolchain.set ? plan.platform.fields.toolchain.value : std::string())); @@ -3599,6 +3621,7 @@ std::expected, Error> single_file(const Plan& } const auto out = output_for(std::format("{}-{}", f.system, f.arch), suffix); fs::create_directories(out.parent_path(), ec); + if (auto e = detail::clear_staged_tree(out); !e.empty()) return std::unexpected(Error{e}); fs::copy_file(program, out, fs::copy_options::overwrite_existing, ec); if (ec) return std::unexpected(Error{std::format("cannot write {}: {}", out.string(), ec.message())}); written.push_back(out); @@ -3673,6 +3696,7 @@ std::expected, Error> single_file(const Plan& const auto program = *packed / (stem + suffix); const auto out = output_for(t ? t->str() : plan.triple, suffix); fs::create_directories(out.parent_path(), ec); + if (auto e = detail::clear_staged_tree(out); !e.empty()) return std::unexpected(Error{e}); fs::copy_file(program, out, fs::copy_options::overwrite_existing, ec); if (ec) return std::unexpected(Error{std::format("cannot write {}: {}", out.string(), ec.message())}); written.push_back(out); diff --git a/src/pack/pipeline.cppm b/src/pack/pipeline.cppm index a096f73a9..02e3468eb 100644 --- a/src/pack/pipeline.cppm +++ b/src/pack/pipeline.cppm @@ -537,18 +537,33 @@ std::optional member_plan(PackRun& run, GroupJob& g, MemberJob& m) { break; } } - if (mainBinary.empty()) { - // Fall back to the first binary target if package.name doesn't match. - for (auto& lu : ctx.plan.linkUnits) { - if (is_program_link_unit(lu)) { - mainBinary = ctx.outputDir / lu.output; - chosenLu = &lu; - break; - } - } + // SEVERAL PROGRAMS ARE NOT PACKED BY A GUESS (mcpp#911 B3): the program + // named after the package is the package's, and the others are said; with + // none so named, the command names one or ships them all. + std::vector programs; + for (auto const& lu : ctx.plan.linkUnits) + if (is_program_link_unit(lu)) programs.push_back(&lu); + std::string names; + for (auto const* lu : programs) names += (names.empty() ? "" : ", ") + lu->targetName; + if (mainBinary.empty() && programs.size() > 1 && !opts.allPrograms) + return Refusal{2, about(run, m, std::format( + "the package has several programs ({}) and none is named after it\n" + " name one: mcpp pack \n or ship them together: mcpp pack --all-programs", + names))}; + if (mainBinary.empty() && !programs.empty()) { + mainBinary = ctx.outputDir / programs.front()->output; + chosenLu = programs.front(); } if (mainBinary.empty()) return Refusal{1, about(run, m, "no binary target to pack")}; + if (programs.size() > 1 && !opts.allPrograms && run.wantTarget.empty()) { + std::string others; + for (auto const* lu : programs) + if (lu != chosenLu) others += (others.empty() ? "" : ", ") + lu->targetName; + mcpp::ui::note(std::format( + "packing {}, the program named after the package; its other programs are not packed " + "({}): `mcpp pack --all-programs` ships them beside it", chosenLu->targetName, others)); + } // Passed to `make_plan` rather than re-derived from the file: `make_plan` // has only `mainBinary` and would otherwise have to ask the triple and // the manifest the same question a second time. @@ -633,6 +648,13 @@ std::optional member_plan(PackRun& run, GroupJob& g, MemberJob& m) { opts.runtimeFiles.push_back(productDir == "bin" ? u.output.lexically_relative("bin") : std::filesystem::path(u.output.filename())); + // `--all-programs`: the package's other programs, beside the chosen one. + if (opts.allPrograms) + for (auto const* u : programs) + if (u != chosenLu && u->kind == mcpp::build::LinkUnit::Binary) + opts.runtimeFiles.push_back(productDir == "bin" + ? u->output.lexically_relative("bin") + : std::filesystem::path(u->output.filename())); // #634 A3: the Android row reads its closure against the directories // its link declared -- a prebuilt library named through `[runtime] // link_library_dirs` is a file the link used and the device does not diff --git a/src/pack/route.cppm b/src/pack/route.cppm index 5c6fc72f0..6c9fd1804 100644 --- a/src/pack/route.cppm +++ b/src/pack/route.cppm @@ -134,14 +134,27 @@ std::expected route_pack_target(std::string_view request // refuse further in with "not a program in this build", which is a // pre-existing message for any target the application pipeline finds no // executable link unit for. + // + // SEVERAL PROGRAMS ARE NOT DECIDED HERE (mcpp#911 B3): the application + // pipeline takes the one named after the package, says which others it + // left, and refuses when none is so named (`--all-programs` ships them + // together). The first one was taken here before, without a word. const mcpp::manifest::Target* onlyLib = nullptr; - std::size_t libCount = 0; + const mcpp::manifest::Target* firstProgram = nullptr; + std::size_t libCount = 0, programCount = 0; for (auto const& t : m->targets) { - if (t.is_program()) - return PackRoute{ t.name, false, - t.kind == mcpp::manifest::Target::Application }; + if (t.is_program()) { + if (!firstProgram) firstProgram = &t; + ++programCount; + } if (is_library(t)) { onlyLib = &t; ++libCount; } } + if (programCount == 1) + return PackRoute{ firstProgram->name, false, + firstProgram->kind == mcpp::manifest::Target::Application }; + if (programCount > 1) + return PackRoute{ {}, false, + firstProgram->kind == mcpp::manifest::Target::Application }; if (libCount == 1) return PackRoute{ onlyLib->name, true }; if (libCount == 0) return std::unexpected("this package declares no program and no library to pack"); diff --git a/tests/e2e/1040_a_platform_defines_a_target.sh b/tests/e2e/1040_a_platform_defines_a_target.sh index 18a3a7187..960fa5be8 100644 --- a/tests/e2e/1040_a_platform_defines_a_target.sh +++ b/tests/e2e/1040_a_platform_defines_a_target.sh @@ -96,6 +96,21 @@ out=$("$MCPP" build --target x86_64-toyos 2>&1) || fail "a row for another syste grep -q "the artifacts are for toyos, not this machine" <<<"$out" || fail "the build judged a foreign artifact by this machine" "$out" echo " ok an artifact for another system is built and its closure is not judged here" +# The platform names its programs; a build program that names them for the host does not change +# the platform's suffix, and the build says so (mcpp#911 B2). +sed -i 's/^driver = "compiler"$/driver = "compiler"\nexe = "{name}.toy"/' "$t/toy/mcpp.toml" +cat > build.mcpp <<'EOF' +import std; +int main() { std::println("mcpp:artifact-name=exe:{{name}}"); } +EOF +out=$("$MCPP" build --target x86_64-toyos 2>&1) || fail "the build with the program's name did not succeed" "$out" +grep -q 'the platform toy-platform 0.1.0 names it `{name}.toy`: the platform.s name stands' <<<"$out" \ + || fail "the conflict of names is not reported" "$out" +[ -n "$(find target/x86_64-toyos -path '*/bin/toyapp.toy' -type f | head -1)" ] \ + || fail "the program is not named as the platform names it" "$(find target/x86_64-toyos -path '*/bin/*' | head)" +rm build.mcpp +echo " ok the platform's name of a program stands over a build program's" + sed -i '/^\[target.x86_64-toyos\]/,/^platform/d' mcpp.toml out=$("$MCPP" build --target x86_64-toyos 2>&1 || true) grep -q "unknown target 'x86_64-toyos'" <<<"$out" || fail "without the platform the request was not an unknown target" "$out" diff --git a/tests/e2e/1043_several_programs_and_a_dotted_name_are_packed_as_stated.sh b/tests/e2e/1043_several_programs_and_a_dotted_name_are_packed_as_stated.sh new file mode 100755 index 000000000..a274efba0 --- /dev/null +++ b/tests/e2e/1043_several_programs_and_a_dotted_name_are_packed_as_stated.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# requires: elf unix-shell +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1043 -- several programs are packed as stated, and a program whose name has a dot is packed +# (mcpp#911 B1, B3). +# +# A package with several programs and none named after it is not packed by a guess: `mcpp pack` +# refuses and names the two forms, `mcpp pack ` and `mcpp pack --all-programs`. With +# one named after the package, that one is packed and the others are said. A manifest `mcpp pack` +# generates quotes a target name with a dot (`COMMAND.COM`), so the generated project parses. +# +# Criteria: the refusal lists the programs; `--all-programs` ships both under bin/; a single-file +# pack of `COMMAND.COM` builds its launcher and the file runs with the program's exit status. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +mkdir -p "$t/src" +cat > "$t/mcpp.toml" <<'EOF' +[package] +name = "multi" +version = "0.1.0" + +[targets.alpha] +kind = "bin" +main = "src/alpha.cpp" + +[targets."COMMAND.COM"] +kind = "bin" +main = "src/command.cpp" +EOF +echo 'int main() { return 0; }' > "$t/src/alpha.cpp" +echo 'int main() { return 3; }' > "$t/src/command.cpp" +cd "$t" + +set +e; out=$("$MCPP" pack 2>&1); rc=$?; set -e +[ "$rc" != 0 ] || fail "several programs were packed by a guess" "$out" +grep -q "several programs (COMMAND.COM, alpha) and none is named after it" <<<"$out" || fail "the refusal does not list the programs" "$out" +grep -q "mcpp pack --all-programs" <<<"$out" || fail "the refusal does not name --all-programs" "$out" +echo " ok several programs, none named after the package: refused with both forms" + +out=$("$MCPP" pack --all-programs 2>&1) || fail "--all-programs did not pack" "$out" +listing=$(tar tzf target/dist/multi-0.1.0-*.tar.gz) +grep -q '/bin/alpha$' <<<"$listing" && grep -q '/bin/COMMAND.COM$' <<<"$listing" \ + || fail "--all-programs did not ship both programs" "$listing" +echo " ok --all-programs ships every program" + +rm -rf target/dist +out=$("$MCPP" pack COMMAND.COM --pkg-format single-file 2>&1) || fail "the single file of COMMAND.COM was not made" "$out" +file=$(find target/dist -maxdepth 1 -type f -name 'multi-0.1.0-*' | head -1) +[ -n "$file" ] || fail "no single file under target/dist" "$(ls target/dist)" +set +e; "$file"; rc=$?; set -e +[ "$rc" = 3 ] || fail "the single file exited $rc, not 3" +echo " ok a program named with a dot is packed into one file and runs" + +sed -i 's/^name = "multi"/name = "alpha"/' mcpp.toml +out=$("$MCPP" pack 2>&1) || fail "the program named after the package was not packed" "$out" +grep -q "packing alpha, the program named after the package; its other programs are not packed (COMMAND.COM)" <<<"$out" \ + || fail "the programs left out are not said" "$out" +echo " ok the program named after the package is packed and the others are said" +echo "PASS: 1043 several programs and a dotted name are packed as stated" From e6ffe8a80f7bee947ce4815ba53c4f0dff70b521 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 08:11:18 +0900 Subject: [PATCH 13/45] =?UTF-8?q?target:=20clang=20pairs=20with=20the=20Mi?= =?UTF-8?q?nGW-w64=20payload=20(SPEC-016=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider that pairs with the declared compiler family supplies the system: with llvm declared for x86_64-windows-gnu, the mingw provider's payload is installed as for gcc and becomes clang's sysroot, C library and C++ runtime (libstdc++, whose bits/std.cc is the std module); lld links with libgcc and winpthreads. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- modules/toolchain-model/src/model.cppm | 4 ++ src/build/flags.cppm | 11 ++++- src/build/prepare/retarget.cpp | 47 ++++++++++++++++++ src/build/prepare/state.cppm | 7 +++ src/build/prepare/toolchain.cpp | 1 + src/build/prepare/toolchain_decision.cpp | 13 ++++- src/toolchain/hostflags.cppm | 9 +++- src/toolchain/provider.cppm | 3 +- ...1044_clang_pairs_with_the_mingw_payload.sh | 48 +++++++++++++++++++ 9 files changed, 138 insertions(+), 5 deletions(-) create mode 100755 tests/e2e/1044_clang_pairs_with_the_mingw_payload.sh diff --git a/modules/toolchain-model/src/model.cppm b/modules/toolchain-model/src/model.cppm index 953b95eee..50c40934c 100644 --- a/modules/toolchain-model/src/model.cppm +++ b/modules/toolchain-model/src/model.cppm @@ -128,6 +128,10 @@ struct Toolchain { // SPEC-016 §5.2): `[platform.replace]` and `[target..replace]`, // merged in their order. Read by every producer of the row's words. std::vector rowReplace; + // THE PAYLOAD A PAIRED PROVIDER SUPPLIES THE SYSTEM FROM (SPEC-016 §6): + // clang aimed at `x86_64-windows-gnu` takes the MinGW-w64 payload as its + // sysroot, C library and C++ runtime (libstdc++). Empty otherwise. + std::filesystem::path pairedSysroot; // DOES THE TARGET'S C LIBRARY COME FROM A DIRECTORY THAT EXISTED // BEFORE DEPENDENCY RESOLUTION? — `TargetSide::cAbi.prebuilt()`, recorded diff --git a/src/build/flags.cppm b/src/build/flags.cppm index fab91af4a..96b38a5e0 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -1992,11 +1992,18 @@ CompileFlags compute_flags_impl(const BuildPlan& plan, bool packageLinkFlags) { // Not a new condition but the string that names the machine: a mingw // g++ has an empty `crossTargetFlag`, so this is the identity for the // case the paragraph was written about, and the fix for the other one. - f.ld = std::format("{}{}{}{}{}", crossTarget, link_intent_ld, + // CLANG PAIRED WITH THE MinGW-w64 PAYLOAD (SPEC-016 §6): the payload + // is the sysroot, libstdc++ and libgcc its runtime, lld the linker; + // `-pthread` because the payload's threads are winpthreads, which the + // clang driver does not add by itself. + const std::string paired = plan.toolchain.pairedSysroot.empty() ? std::string{} + : " --no-default-config --sysroot=" + ninjaEsc(plan.toolchain.pairedSysroot) + + " -stdlib=libstdc++ -rtlib=libgcc -fuse-ld=lld -pthread"; + f.ld = std::format("{}{}{}{}{}{}", crossTarget, paired, link_intent_ld, user_ldflags, mingw_stdexp, link_extra); // `-lstdc++exp` is named explicitly, so swapping g++ for gcc would not // drop it — the C line has to leave it out. - f.ldC = std::format("{}{}{}{}", crossTarget, link_intent_ld, + f.ldC = std::format("{}{}{}{}{}", crossTarget, paired, link_intent_ld, user_ldflags, link_extra); return f; } diff --git a/src/build/prepare/retarget.cpp b/src/build/prepare/retarget.cpp index 8a4f5baaf..0e0993463 100644 --- a/src/build/prepare/retarget.cpp +++ b/src/build/prepare/retarget.cpp @@ -24,6 +24,7 @@ import mcpp.toolchain.dialect; import mcpp.toolchain.fingerprint; import mcpp.toolchain.msvc; import mcpp.toolchain.registry; +import mcpp.toolchain.gcc; // the std module source of a paired libstdc++ import mcpp.toolchain.linkmodel; import mcpp.toolchain.lifecycle; import mcpp.toolchain.stdmod; @@ -471,4 +472,50 @@ void step2_row_replacements(PrepareState& state) { } } +// THE SYSTEM A PAIRED PROVIDER SUPPLIES (SPEC-016 §6). The MinGW-w64 provider +// pairs with gcc and with llvm; with llvm, the payload its row names is +// installed as for gcc and becomes clang's sysroot: the C library, libstdc++ +// (whose `bits/std.cc` is the std module) and the GCC runtime. +std::expected step2_paired_system(PrepareState& state) { + state.tc->pairedSysroot.clear(); + if (state.pairedSystem.empty() || state.tc->compiler != mcpp::toolchain::CompilerId::Clang) return {}; + auto spec = mcpp::toolchain::parse_toolchain_spec(state.pairedSystem); + auto target = mcpp::toolchain::triple::parse(state.tc->targetTriple); + if (!spec || !target) return {}; + spec->target = *target; + const auto pkg = mcpp::toolchain::to_xim_package(*spec); + auto cfg = state.get_cfg(true); + if (!cfg) return std::unexpected(cfg.error()); + mcpp::fetcher::Fetcher fetcher(**cfg); + mcpp::fetcher::InstallProgressHandler progress; + auto payload = fetcher.resolve_xpkg_path(pkg.target(), mcpp::fetcher::PayloadIntent::Use, &progress); + if (!payload) + return std::unexpected(std::format( + "target '{}' with {} takes its system from the {} payload ({}), which is not available: {}", + state.tc->targetTriple, state.tc->label(), state.pairedSystem, pkg.target(), + payload.error().message)); + auto& tc = *state.tc; + tc.pairedSysroot = payload->root; + // The payload's own C++ driver names the target directory its libstdc++ + // is installed under (`//include/c++/`). + std::filesystem::path gxx; + std::error_code dirEc; + for (auto const& e : std::filesystem::directory_iterator(payload->root / "bin", dirEc)) { + const auto name = e.path().stem().string(); + if (name.ends_with("-g++") && (gxx.empty() || name < gxx.stem().string())) gxx = e.path(); + } + if (auto src = mcpp::toolchain::gcc::find_std_module_source(gxx, spec->version)) { + tc.stdlibId = "libstdc++"; + tc.stdlibVersion = spec->version; + tc.stdModuleSource = *src; + std::error_code ec; + const auto compat = src->parent_path() / "std.compat.cc"; + tc.stdCompatSource = std::filesystem::exists(compat, ec) ? compat : std::filesystem::path{}; + tc.hasImportStd = true; + } + mcpp::ui::info("Pairs", std::format("{} with the {} payload of {} ({})", tc.label(), state.pairedSystem, + state.tc->targetTriple, state.targetChoice.provider)); + return {}; +} + } // namespace mcpp::build diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index 0e9a0e5ec..d19eb3e24 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -354,6 +354,11 @@ struct PrepareState { std::string targetPinCandidate; bool targetPinIsCapability = false; std::string targetRowPin; + // A PROVIDER THAT PAIRS WITH THE DECLARED COMPILER SUPPLIES THE SYSTEM + // (SPEC-016 §6): the payload the row's own toolchain names (the MinGW-w64 + // payload of `x86_64-windows-gnu`) is then the declared clang's sysroot and + // C++ runtime. Empty when the compiler is the row's own. + std::string pairedSystem; std::string targetRowName; TargetChoice targetChoice; // The request names a row no definition knows yet, assumed while the platform @@ -1268,6 +1273,8 @@ std::expected step13_finish_platform(PrepareState& state, Bui std::expected step2_retarget_for_retargetable_driver(PrepareState& state); // The row's defaults the platform and the project replace (SPEC-016 §5.2). void step2_row_replacements(PrepareState& state); +// The system a paired provider supplies to the declared compiler (SPEC-016 §6). +std::expected step2_paired_system(PrepareState& state); // The platform root a first pass asks the second to read in P1, once. std::optional take_platform_restart(); // The identity a first pass found the C library names (SPEC-016 §5), taken once. diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index 3fe184658..e563a53ce 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -2293,6 +2293,7 @@ std::expected phase2_define_toolchain_resolver(PrepareState& if (auto r = step2_apply_local_toolchain(state); !r) return std::unexpected(r.error()); if (auto r = step2_retarget_for_retargetable_driver(state); !r) return std::unexpected(r.error()); step2_row_replacements(state); + if (auto r = step2_paired_system(state); !r) return std::unexpected(r.error()); if (auto r = step2_bind_msvc_toolset(state); !r) return std::unexpected(r.error()); if (auto r = step2_resolve_base(state); !r) return std::unexpected(r.error()); if (auto r = step2_resolve_run_under(state); !r) return std::unexpected(r.error()); diff --git a/src/build/prepare/toolchain_decision.cpp b/src/build/prepare/toolchain_decision.cpp index 9d399c73e..f589ab95d 100644 --- a/src/build/prepare/toolchain_decision.cpp +++ b/src/build/prepare/toolchain_decision.cpp @@ -20,6 +20,7 @@ import mcpp.toolchain.fingerprint; import mcpp.toolchain.registry; import mcpp.toolchain.linkmodel; import mcpp.toolchain.triple; +import mcpp.toolchain.target_rows; // For `resolve_version_match` / `list_installed_versions`: a bare compiler // family named by the dependency graph resolves to a concrete version through // exactly the path `mcpp toolchain default ` uses. @@ -427,7 +428,17 @@ std::expected phase5_toolchain_after_graph(PrepareState& stat && tc_origin_is_user_explicit(state.tcOrigin) && state.tcSpec.has_value()) { auto declared = mcpp::toolchain::parse_toolchain_spec(*state.tcSpec); auto rowTc = mcpp::toolchain::parse_toolchain_spec(state.targetRowPin); - if (declared && rowTc && declared->family != rowTc->family) { + // A provider that pairs with the declared family supplies the system + // with it (SPEC-016 §6): the row's payload becomes that compiler's. + const bool paired = declared && rowTc && declared->family != rowTc->family + && std::ranges::any_of(mcpp::toolchain::target::providers(), [&](auto const* p) { + return p->name == state.targetChoice.provider + && mcpp::toolchain::target::serves(*p, state.targetRowName) + && std::ranges::find(p->pairs, mcpp::toolchain::family_name(declared->family)) + != p->pairs.end(); + }); + if (paired) state.pairedSystem = state.targetRowPin; + else if (declared && rowTc && declared->family != rowTc->family) { refusal::record(refusal::Code::ConventionUnreplaced); return std::unexpected(std::format( "target '{}' takes its C library from the '{}' payload, and " diff --git a/src/toolchain/hostflags.cppm b/src/toolchain/hostflags.cppm index 4f35871f2..fdd32723e 100644 --- a/src/toolchain/hostflags.cppm +++ b/src/toolchain/hostflags.cppm @@ -409,7 +409,14 @@ std::vector host_compile_tokens(const Toolchain& tc, // link on `__hash_memory`, which an inline function in the newer headers // names and the older dylib does not export (mcpp#630). const bool cxxFromPayload = !opt.cxxFromGraph && !opt.appleSdkCxxHeaders; - if (bypassCfg && !graphSuppliesTarget && cxxFromPayload) { + // A PAIRED PROVIDER'S PAYLOAD IS THE SYSTEM (SPEC-016 §6): its sysroot + // holds the C library, libstdc++ and the GCC runtime, and the driver + // finds them there once it is told the sysroot and the C++ library. + if (bypassCfg && !tc.pairedSysroot.empty()) { + out.push_back("--no-default-config"); + out.push_back("--sysroot=" + esc(tc.pairedSysroot)); + out.push_back("-stdlib=libstdc++"); + } else if (bypassCfg && !graphSuppliesTarget && cxxFromPayload) { for (auto& t : dm.compile_tokens(esc, opt.clangStdlibSelect)) out.push_back(t); // On AArch64 the runtime choice changes FMV/outline-atomics codegen. diff --git a/src/toolchain/provider.cppm b/src/toolchain/provider.cppm index 1bc32adf5..438be0a49 100644 --- a/src/toolchain/provider.cppm +++ b/src/toolchain/provider.cppm @@ -89,7 +89,8 @@ ProviderCapabilities capabilities_for(const Toolchain& tc) { bool msvc_target = is_msvc_target(tc); caps.has_scan_deps = true; // clang-scan-deps lives beside clang++ - caps.stdlib_id = msvc_target ? "msvc-stl" : "libc++"; + caps.stdlib_id = msvc_target ? "msvc-stl" + : !tc.pairedSysroot.empty() ? "libstdc++" : "libc++"; caps.archive_format = "llvm-ar"; break; } diff --git a/tests/e2e/1044_clang_pairs_with_the_mingw_payload.sh b/tests/e2e/1044_clang_pairs_with_the_mingw_payload.sh new file mode 100755 index 000000000..fba2bfb11 --- /dev/null +++ b/tests/e2e/1044_clang_pairs_with_the_mingw_payload.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# requires: llvm mingw-cross wine +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1044 -- clang pairs with the MinGW-w64 payload (SPEC-016 §6). +# +# The `mingw` provider of `x86_64-windows-gnu` pairs with gcc and with llvm. With llvm declared, +# the payload its row names is installed as for gcc and is clang's sysroot: the C library, +# libstdc++ (whose `bits/std.cc` is the std module) and the GCC runtime; lld links. +# +# Criteria: the build reports the pairing; the compile line carries the payload as `--sysroot` +# and `-stdlib=libstdc++`; the program, which uses `import std` and `std::println`, runs under +# wine; its imports are the system's DLLs only. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +mkdir -p "$t/src" +cat > "$t/mcpp.toml" <<'EOF' +[package] +name = "paired" +version = "0.1.0" +EOF +cat > "$t/src/main.cpp" <<'EOF' +import std; +int main() { + std::vector words{"paired", "with", "mingw"}; + std::println("{}", words.size() == 3 ? "clang and libstdc++" : "wrong"); +} +EOF +cd "$t" + +out=$("$MCPP" build --target x86_64-windows-gnu --toolchain "llvm@${LLVM_VERSION}" 2>&1) || fail "the paired build failed" "$out" +grep -q "Pairs clang .* with the gcc@.* payload of x86_64-windows-gnu (mingw)" <<<"$out" || fail "the pairing is not reported" "$out" +ninja=$(find target/x86_64-windows-gnu -name build.ninja | head -1) +cxx=$(grep '^cxxflags' "$ninja") +grep -q -- "--sysroot=.*mingw-cross-gcc" <<<"$cxx" && grep -q -- "-stdlib=libstdc++" <<<"$cxx" \ + || fail "the compile line does not name the payload and libstdc++" "$cxx" +echo " ok clang compiles against the MinGW-w64 payload and its libstdc++" + +exe=$(find target/x86_64-windows-gnu -path '*/bin/paired.exe' -type f | head -1) +[ -n "$exe" ] || fail "no paired.exe" +run=$(wine "$exe" 2>/dev/null | tr -d '\r') || fail "the program did not run under wine" "$run" +[ "$run" = "clang and libstdc++" ] || fail "the program printed '$run'" +dlls=$("$_e2e_registry_base/xim-x-llvm/${LLVM_VERSION}/bin/llvm-objdump" -p "$exe") +if grep 'DLL Name' <<<"$dlls" | grep -viqE 'kernel32|msvcrt|api-ms-win|ucrtbase'; then + fail "the program needs a DLL of the payload" "$(grep 'DLL Name' <<<"$dlls")" +fi +echo " ok import std and std::println run under wine, with the system's DLLs only" +echo "PASS: 1044 clang pairs with the mingw payload" From d6d351a85cfd7b2a20d22b24bca8aa187a541107 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 08:22:56 +0900 Subject: [PATCH 14/45] a required capability takes its package's default provider; tests use mtest 0.5.0 (mcpp#911 G7) - `[defaults.""] = `: the provider of a capability a package requires when neither the graph nor the platform provides it; a selector that names the target wins over `*`. - The dev-dependencies, the skeleton and the docs name mtest 0.5.0. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- docs/05-dependencies.md | 4 +- docs/zh/05-dependencies.md | 4 +- mcpp.toml | 2 +- modules/buildmcpp/mcpp.toml | 2 +- modules/graph/mcpp.toml | 2 +- modules/manifest/mcpp.toml | 2 +- modules/manifest/src/toml.cppm | 19 +++++ modules/manifest/src/types.cppm | 11 +++ modules/toolchain-model/mcpp.toml | 2 +- modules/versioning/mcpp.toml | 2 +- src/build/prepare/graph.cpp | 6 +- src/build/prepare/platform.cpp | 67 ++++++++++++++++++ src/build/prepare/state.cppm | 4 ++ src/build/test_protocol.cppm | 2 +- ...d_capability_takes_its_default_provider.sh | 69 +++++++++++++++++++ tests/unit/test_pack_distribution.cpp | 4 +- 16 files changed, 188 insertions(+), 14 deletions(-) create mode 100755 tests/e2e/1045_a_required_capability_takes_its_default_provider.sh diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index 5e0db2e78..334dfec09 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -67,8 +67,8 @@ not deprecated: ```toml [dev-dependencies] -mcpplibs.mtest = "0.4.0" -"mcpplibs.mtest.gtest" = "0.4.0" +mcpplibs.mtest = "0.5.0" +"mcpplibs.mtest.gtest" = "0.5.0" ``` ```toml diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 249b29ecf..72ea1cb7e 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -62,8 +62,8 @@ mcpp 把它读作这些点所拼写的选择器,警告该拼法已弃用(自 ```toml [dev-dependencies] -mcpplibs.mtest = "0.4.0" -"mcpplibs.mtest.gtest" = "0.4.0" +mcpplibs.mtest = "0.5.0" +"mcpplibs.mtest.gtest" = "0.5.0" ``` ```toml diff --git a/mcpp.toml b/mcpp.toml index 2058f96ca..087ea4986 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -164,7 +164,7 @@ members = [ # `mcpp build` ignores [dev-dependencies]; only `mcpp test` resolves them. [dev-dependencies] -mcpplibs.mtest = "0.4.1" +mcpplibs.mtest = "0.5.0" # Everything else uses M5.0 conventions: # - target = mcpp (kind=bin inferred from src/main.cpp) diff --git a/modules/buildmcpp/mcpp.toml b/modules/buildmcpp/mcpp.toml index a9af1116b..539e178fa 100644 --- a/modules/buildmcpp/mcpp.toml +++ b/modules/buildmcpp/mcpp.toml @@ -41,4 +41,4 @@ mcpp.source-kind = { path = "../source-kind" } mcpp.toolchain-model = { path = "../toolchain-model" } [dev-dependencies] -mcpplibs.mtest = "0.4.1" +mcpplibs.mtest = "0.5.0" diff --git a/modules/graph/mcpp.toml b/modules/graph/mcpp.toml index 9395551a5..fc6cd5cce 100644 --- a/modules/graph/mcpp.toml +++ b/modules/graph/mcpp.toml @@ -17,4 +17,4 @@ description = "Algorithms over index-addressed directed graphs: topological orde license = "Apache-2.0" [dev-dependencies] -mcpplibs.mtest = "0.4.1" +mcpplibs.mtest = "0.5.0" diff --git a/modules/manifest/mcpp.toml b/modules/manifest/mcpp.toml index d32bc5698..3656496ec 100644 --- a/modules/manifest/mcpp.toml +++ b/modules/manifest/mcpp.toml @@ -30,4 +30,4 @@ mcpplibs.os = "0.4.1" # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. [dev-dependencies] -mcpplibs.mtest = "0.4.1" +mcpplibs.mtest = "0.5.0" diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index a12dcec71..5c2c9f2c5 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -3881,6 +3881,25 @@ std::expected parse_document(const t::Document& documen if (!ov) return std::unexpected(error(origin, ov.error())); m.hostPlatform = std::move(*ov); } + // [defaults.""] — the provider of a required capability when + // nothing else provides it (mcpp#911 G7): ` = "@"` + // or a dependency table, the selector a target selector (`linux`, + // `cfg(os = "freebsd")`, a triple, `*`). + if (auto* dt = doc->get_table("defaults")) { + mcpp::manifest::platform::Reader rd{origin, mcpp::manifest::platform::Tier::Manifest, + &m.schemaWarnings}; + for (auto const& [cap, body] : *dt) { + if (!body.is_table()) + return std::unexpected(error(origin, std::format( + "[defaults.\"{}\"] must be a table of = , for example " + "linux = \"openkal-linux@^0.25\"", cap), body.position)); + for (auto const& [sel, v] : body.as_table()) { + auto r = rd.ref(v, std::format("[defaults.\"{}\"].{}", cap, toml_header_key(sel)), false, false); + if (!r) return std::unexpected(error(origin, r.error(), v.position)); + m.capabilityDefaults.push_back({ cap, sel, std::move(*r), v.position.line }); + } + } + } // [lib] — library root convention (cargo-style). if (auto v = doc->get_string("lib.path")) { diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 503d308d9..5ac8fb9d7 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -2301,6 +2301,17 @@ struct Manifest { // alone: one image has one C library (openkal's boundary). Unioned from // `[package]` and every matching `[target.]`, as `requires_` is. std::vector privateRequires; + // `[defaults.""] = ` (mcpp#911 G7; SPEC-002 + // §3.6): the provider of a capability this package requires when neither + // the graph nor the platform provides it, per target selector. The layer + // mechanism of the platform, for any package: graph > platform > default. + struct CapabilityDefault { + std::string capability; + std::string selector; + mcpp::manifest::platform::PackageRef ref; + int line = 0; + }; + std::vector capabilityDefaults; // `requires_abi = { threads = true, exceptions = true }` at package // level, and per feature. A statement that the ARTEFACT's ABI has a // switch on, compared at resolution with the root's diff --git a/modules/toolchain-model/mcpp.toml b/modules/toolchain-model/mcpp.toml index 31efa4d01..f36fd7795 100644 --- a/modules/toolchain-model/mcpp.toml +++ b/modules/toolchain-model/mcpp.toml @@ -29,7 +29,7 @@ mcpplibs.os = "0.4.1" # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. [dev-dependencies] -mcpplibs.mtest = "0.4.1" +mcpplibs.mtest = "0.5.0" # build.mcpp compiles data/builtin.toml (the built-in target rows and providers) # into a module of static tables, with mcpp's own TOML parser. diff --git a/modules/versioning/mcpp.toml b/modules/versioning/mcpp.toml index 0c8f15695..29f7994c2 100644 --- a/modules/versioning/mcpp.toml +++ b/modules/versioning/mcpp.toml @@ -25,4 +25,4 @@ license = "Apache-2.0" # state its contract in isolation; the root `tests/unit/` states the same facts # as the engine consumes them, and the pair is the cross-check. [dev-dependencies] -mcpplibs.mtest = "0.4.1" +mcpplibs.mtest = "0.5.0" diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index 4f34bfa3d..545cd933d 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -2425,7 +2425,11 @@ std::expected phase4b_graph_worklist(PrepareState& state) { auto pushed = step4b_apply_platform(state); if (!pushed) return std::unexpected(pushed.error()); if (auto r = step4b_check_platform_versions(state); !r) return r; - if (!*pushed) break; + // AND A PACKAGE'S OWN DEFAULTS AFTER THE PLATFORM'S LAYERS (mcpp#911 + // G7): graph > platform > default. + auto defaulted = step4b_capability_defaults(state); + if (!defaulted) return std::unexpected(defaulted.error()); + if (!*pushed && !*defaulted) break; if (auto r = drain(); !r) return r; } if (auto r = step4b_check_platform_versions(state); !r) return r; diff --git a/src/build/prepare/platform.cpp b/src/build/prepare/platform.cpp index 20d94d976..9bc207fec 100644 --- a/src/build/prepare/platform.cpp +++ b/src/build/prepare/platform.cpp @@ -662,6 +662,73 @@ std::expected step4b_apply_platform(PrepareState& state) { return pushed; } +// THE CAPABILITY SLOTS OF ORDINARY PACKAGES (mcpp#911 G7; SPEC-002 §3.6). A +// package that requires a capability (`requires = ["mcpp:kernel-abi=openkal"]`) +// and states its default providers (`[defaults."mcpp:kernel-abi=openkal"] linux +// = "openkal-linux@^0.25"`) takes the default for the target only when no +// package of the graph provides the capability -- the consumer's choice, or +// the platform's layer, added in the rounds before. The default is the +// package's dependency, walked like one. +std::expected step4b_capability_defaults(PrepareState& state) { + std::set provided; + for (auto const& p : state.packages) + for (auto const& c : p.manifest.provides) provided.insert(c); + const auto ctx = platform_cfg_context(state); + const auto matches = matcher(ctx); + bool pushed = false; + for (std::size_t i = 0; i < state.packages.size(); ++i) { + auto const& pm = state.packages[i].manifest; + if (pm.capabilityDefaults.empty()) continue; + // A selector that names the target wins over `*`, which is the default + // of every other target. + std::vector order; + for (auto const& d : pm.capabilityDefaults) if (d.selector != "*") order.push_back(&d); + for (auto const& d : pm.capabilityDefaults) if (d.selector == "*") order.push_back(&d); + std::set done; + for (auto const* dp : order) { + auto const& d = *dp; + if (done.contains(d.capability) || provided.contains(d.capability)) continue; + if (std::ranges::find(pm.requires_, d.capability) == pm.requires_.end()) continue; + if (d.selector != "*" && !matches(d.selector)) continue; + done.insert(d.capability); + const auto key = item_key(d.ref); + const auto mark = std::format("default:{}:{}", pm.package.name, d.capability); + if (key.empty() || state.platformInjected.contains(mark)) continue; + WorkItem item; + item.name = key; + item.spec = d.ref.spec; + if (item.spec.shortName.empty()) item.spec.shortName = known_short_name(d.ref); + if (item.spec.namespace_.empty()) { + item.spec.namespace_ = std::string(mcpp::pm::kDefaultNamespace); + item.spec.namespaceOmitted = true; + } + if (item.spec.candidates.empty()) + item.spec.candidates = {{ .namespace_ = item.spec.namespace_, + .shortName = item.spec.shortName }}; + item.spec.declaredIn = std::format("{}'s default for {} ({})", pm.package.name, + d.capability, d.selector); + item.requestedBy = std::format("{} (default for {})", pm.package.name, d.capability); + item.originalConstraint = item.spec.version; + item.consumerDepIndex = i == 0 ? kMainConsumer : i - 1; + item.resolveRoot = i == 0 ? std::filesystem::path{} : state.packages[i].root; + // The package declares it, as it would by hand: the readers of its + // edges (the link's closure among them) see it. + if (i == 0) state.m->dependencies[key] = item.spec; + else { + state.packages[i].manifest.dependencies[key] = item.spec; + if (i - 1 < state.dep_manifests.size() && state.dep_manifests[i - 1]) + state.dep_manifests[i - 1]->dependencies[key] = item.spec; + } + state.platformInjected.insert(mark); + mcpp::ui::info("Default", std::format("{} for {} of {} ({})", mp::display(d.ref), + d.capability, pm.package.name, d.selector)); + state.worklist.push_back(std::move(item)); + pushed = true; + } + } + return pushed; +} + // THE PLATFORM'S VERSION GROUP IS A CONSTRAINT (SPEC-013 §7.5): a request of // one of its members, by version, resolves to the group's version when the // request admits it, and is refused naming both when it does not. A package diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index d19eb3e24..0ce3f90cd 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -1254,6 +1254,10 @@ std::string named_provider(const PrepareState& state, const mcpp::toolchain::tri void step4b_seed_platform(PrepareState& state); std::expected step4b_apply_platform(PrepareState& state); +// The capabilities packages require and nothing provides: each takes the +// default its package states for the target (mcpp#911 G7). True when one was +// added to the walk. +std::expected step4b_capability_defaults(PrepareState& state); std::expected step4b_check_platform_versions(PrepareState& state); std::expected step4b_pin_to_platform_group(PrepareState& state, const WorkItem& item, const std::string& name, diff --git a/src/build/test_protocol.cppm b/src/build/test_protocol.cppm index 74ce73559..80693045a 100644 --- a/src/build/test_protocol.cppm +++ b/src/build/test_protocol.cppm @@ -18,7 +18,7 @@ inline constexpr int kVersion = 1; // The framework mcpp scaffolds tests with and suggests where a package has // none (docs/01, docs/08): mtest, by this release, from its repository. -inline constexpr std::string_view kMtestRelease = "0.4.0"; +inline constexpr std::string_view kMtestRelease = "0.5.0"; inline constexpr std::string_view kMtestRepo = "https://github.com/mcpp-community/mtest"; // The protocol's variables, removed from the environment a program inherits diff --git a/tests/e2e/1045_a_required_capability_takes_its_default_provider.sh b/tests/e2e/1045_a_required_capability_takes_its_default_provider.sh new file mode 100755 index 000000000..4ffb9c00f --- /dev/null +++ b/tests/e2e/1045_a_required_capability_takes_its_default_provider.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# requires: elf +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1045 -- a required capability takes its package's default provider (mcpp#911 G7; SPEC-002 §3.6). +# +# A package requires a capability (`requires = ["acme:greeter"]`) and states its default providers +# per target (`[defaults."acme:greeter"] linux = ..., "*" = ...`). With no provider in the graph, +# the default for the target is the package's dependency; a selector that names the target wins +# over `*`. A provider the consumer brings is used instead, and no default is added. +# +# Criteria: the build reports the default; the program returns the default's value (1, Linux); +# with the consumer depending on the other provider it returns that one's (2) and no default is +# reported. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +mkdir -p "$t/greet-a/src" "$t/greet-b/src" "$t/lib/src" "$t/app/src" +for p in a b; do +cat > "$t/greet-$p/mcpp.toml" < "$t/greet-a/src/a.cpp" +echo 'extern "C" int greet() { return 2; }' > "$t/greet-b/src/b.cpp" +cat > "$t/lib/mcpp.toml" <<'EOF' +[package] +name = "lib" +version = "0.1.0" +requires = ["acme:greeter"] + +[targets.lib] +kind = "lib" + +[defaults."acme:greeter"] +linux = { path = "../greet-a" } +"*" = { path = "../greet-b" } +EOF +echo 'extern "C" int greet(); int lib_greet() { return greet(); }' > "$t/lib/src/lib.cpp" +cat > "$t/app/mcpp.toml" <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[dependencies] +lib = { path = "../lib" } +EOF +echo 'int lib_greet(); int main() { return lib_greet(); }' > "$t/app/src/main.cpp" +cd "$t/app" + +out=$("$MCPP" build 2>&1) || fail "the build failed" "$out" +grep -q "Default .*greet-a for acme:greeter of lib (linux)" <<<"$out" || fail "the default is not reported" "$out" +exe=$(find target -path '*/bin/app' -type f | head -1) +set +e; "$exe"; rc=$?; set -e +[ "$rc" = 1 ] || fail "the program returned $rc, not the Linux default's 1" +echo " ok the target's default provides the capability, over \`*\`" + +printf 'greet-b = { path = "../greet-b" }\n' >> mcpp.toml +out=$("$MCPP" build 2>&1) || fail "the build with the consumer's provider failed" "$out" +grep -q "Default " <<<"$out" && fail "a default was added though the graph provides the capability" "$out" +set +e; "$exe"; rc=$?; set -e +[ "$rc" = 2 ] || fail "the program returned $rc, not the consumer's provider's 2" +echo " ok the consumer's provider is used, and no default is added" +echo "PASS: 1045 a required capability takes its default provider" diff --git a/tests/unit/test_pack_distribution.cpp b/tests/unit/test_pack_distribution.cpp index 7857e5060..833fc629c 100644 --- a/tests/unit/test_pack_distribution.cpp +++ b/tests/unit/test_pack_distribution.cpp @@ -261,8 +261,8 @@ UTest the_windows_launcher_is_a_program{"PackDistribution/TheWindowsLauncherIsAP expect(ne(src.find("(\"windows-\" + arch)"), std::string::npos)) << src; expect(ne(src.find("os::process::exec"), std::string::npos)) << src; const auto manifest = d::exe_launcher_manifest("hello", "1.0.0"); - expect(ne(manifest.find("mcpplibs.os = \"0.4.0\""), std::string::npos)) << manifest; - expect(ne(manifest.find("[targets.hello]"), std::string::npos)) << manifest; + expect(ne(manifest.find("mcpplibs.os = \"0.4.1\""), std::string::npos)) << manifest; + expect(ne(manifest.find("[targets.\"hello\"]"), std::string::npos)) << manifest; }}; // The POSIX launcher, run through a link to it from another directory, finds From da989b162d98ef1a217acc953d90d173f74ea5ad Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 08:36:46 +0900 Subject: [PATCH 15/45] a patch names files from the package's root; mcpp patch edit and save (mcpp#911 G8) - The patch root is the directory of the package's resolved manifest inside its archive, or the edge's `patch_root`; a patch written from the archive's top applies as written and the build says so. - `mcpp patch edit ` copies the package root as the build reads it; `mcpp patch save ` writes the difference as the edge's next patch and adds it to the edge (the dependency editor writes patches, and replaces a default namespace's dotted spelling). Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- docs/05-dependencies.md | 11 ++ docs/zh/05-dependencies.md | 6 + modules/manifest/src/dep_spec.cppm | 4 + modules/manifest/src/toml.cppm | 46 +++-- src/build/prepare.cppm | 3 + src/build/prepare/graph.cpp | 10 +- src/build/prepare/graph_load.cpp | 29 ++- src/build/prepare/plan.cpp | 5 + src/build/prepare/state.cppm | 3 +- src/cli.cppm | 18 +- src/cli/cmd_patch.cpp | 147 +++++++++++++++ src/cli/cmd_patch.cppm | 20 +++ src/pm/patch.cppm | 167 +++++++++++++++++- ..._edited_and_its_change_saved_as_a_patch.sh | 53 ++++++ tests/unit/test_patch.cpp | 55 ++++++ 15 files changed, 556 insertions(+), 21 deletions(-) create mode 100644 src/cli/cmd_patch.cpp create mode 100644 src/cli/cmd_patch.cppm create mode 100755 tests/e2e/1046_a_dependency_is_edited_and_its_change_saved_as_a_patch.sh diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index 334dfec09..a4723d1f4 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -666,6 +666,17 @@ verified source. searched for elsewhere and no line ending is converted. Paths in a patch carry one leading directory (`a/`, `b/`), which is removed. Renames, copies and binary changes are refused. +- A patch names files from the **package's root**: the directory of the + package's resolved manifest inside its archive (`run/` of an archive whose + descriptor reads `mcpp = "*/run/mcpp.toml"`), so that one patch applies to + every version whose layout is the same. `patch_root = ""` on the edge + names another directory, relative to the package's root and inside its + source. A patch written from the archive's top, as before 2026.11.9, applies + as written, and the build says so. +- `mcpp patch edit ` copies the package's root, as the build reads + it (its patches applied), to `target/patch//`; `mcpp patch save + ` writes the change as the edge's next patch, + `patches/-.patch`, and adds it to the edge's `patches`. - Version and git dependencies may carry patches; a `path` dependency is a tree its consumer owns and is refused with them. - `mcpp.lock` names the variant and each patch by its path and SHA-256; diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 72ea1cb7e..66e9ea4c9 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -575,6 +575,12 @@ updater = { path = "../updater", artifacts = ["updater"] } - 应用器是 mcpp 自己的,而且是精确的:一个 hunk 只在其头部所写的位置应用,每一行上下文与每一行 删除都必须逐字节相符,否则构建被拒绝,并指出补丁、文件、hunk 与行号。不在别处查找,不转换换行。 补丁中的路径带一级前导目录(`a/`、`b/`),应用时去掉。改名、复制与二进制改动被拒绝。 +- 补丁中的文件以**包根**为起点:包解析得到的清单在归档中所在的目录(描述符写 + `mcpp = "*/run/mcpp.toml"` 时为归档中的 `run/`),因而布局相同的各版本可共用一个补丁。边上的 + `patch_root = ""` 另指一个目录,相对包根,且在包的源码之内。2026.11.9 之前以归档顶层为起点写的 + 补丁按原写法应用,构建给出说明。 +- `mcpp patch edit <依赖>` 把构建所读的包根(已应用其补丁)复制到 `target/patch/<依赖>/`; + `mcpp patch save <依赖>` 把改动写为该边的下一个补丁 `patches/<名>-.patch`,并加入边的 `patches`。 - 版本依赖与 git 依赖可以带补丁;`path` 依赖是使用者自己拥有的源码树,带补丁时被拒绝。 - `mcpp.lock` 记录变体,以及每个补丁的路径与 SHA-256;`mcpp emit sbom` 把它们写作组件的 pedigree。 - 一个程序只持有一个包的一个变体。同一配置中的两个程序(工作区的成员)可以各用自己的变体;一个 diff --git a/modules/manifest/src/dep_spec.cppm b/modules/manifest/src/dep_spec.cppm index d89b7dcfe..6c7869701 100644 --- a/modules/manifest/src/dep_spec.cppm +++ b/modules/manifest/src/dep_spec.cppm @@ -124,6 +124,10 @@ struct DependencySpec { // edges only: a `path` dependency is a tree its consumer already owns. std::vector patches; std::string patchesDigest; + // `patch_root` (mcpp#911 G8): the directory the patches name their files + // from, relative to the package's root (the directory of its resolved + // manifest); empty: that root. + std::string patchRoot; // `embed = true`: the dependency's sources (as its description selects // them, less `exclude`) are compiled INTO this consumer with the // consumer's flags and the dependency's include directories and macros, diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 5c2c9f2c5..77355de26 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -589,6 +589,8 @@ struct DependencyTextEdit { std::string shortName; std::string version; std::vector features; + // The edge's patches, as the manifest writes them (relative to it). + std::vector patches; bool dev = false; }; @@ -2331,7 +2333,7 @@ std::expected parse_document(const t::Document& documen || k == "backend" || k == "tools" || k == "artifacts" || k == "host-module" || k == "reexport" || k == "linkage" - || k == "patches" || k == "embed" || k == "exclude"; + || k == "patches" || k == "patch_root" || k == "embed" || k == "exclude"; }; // What makes a table an inline dep spec is that it names a SOURCE. This // used to be "every key is known", which quietly coupled two unrelated @@ -2544,6 +2546,13 @@ std::expected parse_document(const t::Document& documen spec.patches.push_back(pp.lexically_normal().generic_string()); } } + if (auto it = sub.find("patch_root"); it != sub.end()) { + const std::filesystem::path pr = it->second.is_string() ? it->second.as_string() : std::string{}; + if (!it->second.is_string() || pr.is_absolute()) + return std::unexpected(error(origin, std::format( + "[{}.\"{}\"] patch_root must be a directory relative to the package's root", section, fqName))); + spec.patchRoot = pr.lexically_normal().generic_string(); + } if (auto it = sub.find("embed"); it != sub.end()) { if (!it->second.is_bool()) return std::unexpected(error(origin, std::format( @@ -5709,18 +5718,27 @@ upsert_dependency_text(std::string_view source, } } + for (auto const& p : edit.patches) + for (unsigned char ch : p) + if (ch < 0x20 || ch == 0x7f || ch == '"' || ch == '\\') + return std::unexpected(std::format( + "dependency patch '{}' contains an unsafe TOML character", p)); + auto quoted_list = [](const std::vector& items) { + std::string list; + for (auto const& item : items) { + if (!list.empty()) list += ", "; + list += std::format("\"{}\"", item); + } + return list; + }; std::string value; - if (edit.features.empty()) { + if (edit.features.empty() && edit.patches.empty()) { value = std::format("\"{}\"", edit.version); } else { - std::string featureList; - for (auto const& feature : edit.features) { - if (!featureList.empty()) featureList += ", "; - featureList += std::format("\"{}\"", feature); - } - value = std::format( - "{{ version = \"{}\", features = [{}] }}", - edit.version, featureList); + value = std::format("{{ version = \"{}\"", edit.version); + if (!edit.features.empty()) value += std::format(", features = [{}]", quoted_list(edit.features)); + if (!edit.patches.empty()) value += std::format(", patches = [{}]", quoted_list(edit.patches)); + value += " }"; } struct SectionSpan { @@ -5824,9 +5842,13 @@ upsert_dependency_text(std::string_view source, // Retained flat dotted spellings are removed before canonical namespace // subtable emission. The parsed identity gate above ensures this edits the // exact PackageId, not a short-name substring match. - if (!defaultNamespace) { + // The default namespace's dotted spelling (`mcpplibs.cmdline = ...`) is + // the same package as the bare one and is removed as well. + { + const std::string dotted = defaultNamespace + ? std::format("{}.{}", edit.namespace_, edit.shortName) : spelling; if (auto base = find_section(text, baseHeader)) { - if (auto legacy = key_line_span(text, *base, spelling)) { + if (auto legacy = key_line_span(text, *base, dotted)) { auto eraseEnd = legacy->second; if (eraseEnd < text.size() && text[eraseEnd] == '\n') ++eraseEnd; diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index c812f1082..0f24d118d 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -610,6 +610,9 @@ export struct BuildContext { CompilerChoice compilerChoice; // WHICH ROW AND WHICH PROVIDER THE REQUEST WAS GIVEN (SPEC-016 §4). TargetSelection targetSelection; + // Each package's root as this build read it, by name and by qualified + // name: a patched dependency's is its variant's (`mcpp patch edit`). + std::map packageRoots; // THE SOURCES THIS BUILD USED (mcpp#755), one entry per subject. See // SourceDecision. std::vector sources; diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index 545cd933d..586c6815b 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -1644,9 +1644,15 @@ step4b_acquire_dependency_source(PrepareState& state, WorklistItemCtx& ctx) { const auto variant = (*cfg)->metaCacheDir / "src" / (key.ns.empty() ? key.shortName : key.ns + "." + key.shortName) / mcpp::pm::patch::variant_dir_name(resolvedGitRev.substr(0, 12), spec.patchesDigest); + if (std::filesystem::path(spec.patchRoot).lexically_normal().generic_string().starts_with("..")) + return std::unexpected(std::format( + "dependency '{}': patch_root '{}' leaves the package's source", name, spec.patchRoot)); + std::vector notes; if (auto r = mcpp::pm::patch::materialize(dep_root, spec.patches, - spec.patchesDigest, variant); !r) + spec.patchesDigest, variant, + spec.patchRoot, ¬es); !r) return std::unexpected(std::format("dependency '{}': {}", name, r.error())); + for (auto const& n : notes) mcpp::ui::note(std::format("dependency '{}': {}", name, n)); dep_root = variant; } } @@ -1748,7 +1754,7 @@ step4b_acquire_dependency_source(PrepareState& state, WorklistItemCtx& ctx) { auto loaded = spec.patches.empty() ? state.loadVersionDep(name, key.ns, key.shortName, spec.version) : state.loadVersionDepVariant(name, key.ns, key.shortName, spec.version, - spec.patches, spec.patchesDigest); + spec.patches, spec.patchesDigest, spec.patchRoot); if (!loaded) return std::unexpected(loaded.error()); dep_root = std::move(loaded->first); dep_manifest = std::move(loaded->second); diff --git a/src/build/prepare/graph_load.cpp b/src/build/prepare/graph_load.cpp index 9e56048d9..f527663e2 100644 --- a/src/build/prepare/graph_load.cpp +++ b/src/build/prepare/graph_load.cpp @@ -1379,7 +1379,8 @@ std::expected phase4a_graph_load(PrepareState& state) { const std::string& shortName, const std::string& version, const std::vector& patches, - const std::string& digest) + const std::string& digest, + const std::string& patchRoot) -> std::expected { auto cfg = state.get_cfg(true); @@ -1404,8 +1405,32 @@ std::expected phase4a_graph_load(PrepareState& state) { const auto qualified = ns.empty() ? shortName : ns + "." + shortName; const auto variant = (*cfg)->metaCacheDir / "src" / qualified / mcpp::pm::patch::variant_dir_name(exact, digest); - if (auto r = mcpp::pm::patch::materialize(*ctx.installed, patches, digest, variant); !r) + // THE PATCH ROOT IS THE PACKAGE'S (mcpp#911 G8): the directory of the + // manifest its descriptor names, inside the archive, and the edge's + // `patch_root` below it. + std::filesystem::path packageRoot; + if (!ctx.luaContent) ctx.luaContent = step4a_read_lua_content(state, ctx); + if (ctx.luaContent) { + const auto field = mcpp::manifest::extract_mcpp_field(*ctx.luaContent); + std::vector found; + if (field.kind == mcpp::manifest::McppField::StringPath) + found = mcpp::modgraph::expand_glob(*ctx.installed, field.value); + else if (field.kind != mcpp::manifest::McppField::TableBody) + for (auto pat : { "mcpp.toml", "*/mcpp.toml" }) + if (found = mcpp::modgraph::expand_glob(*ctx.installed, pat); !found.empty()) break; + if (found.size() == 1) + packageRoot = std::filesystem::relative(found.front().parent_path(), *ctx.installed); + } + if (packageRoot == ".") packageRoot.clear(); + const auto patchAt = (packageRoot / patchRoot).lexically_normal(); + if (patchAt.generic_string().starts_with("..")) + return std::unexpected(std::format( + "dependency '{}': patch_root '{}' leaves the package's source", depName, patchRoot)); + std::vector notes; + if (auto r = mcpp::pm::patch::materialize(*ctx.installed, patches, digest, variant, + patchAt == "." ? std::filesystem::path{} : patchAt, ¬es); !r) return std::unexpected(std::format("dependency '{}': {}", depName, r.error())); + for (auto const& n : notes) mcpp::ui::note(std::format("dependency '{}': {}", depName, n)); ctx.installed = variant; return step4a_load_version_dep_read_manifest(state, ctx); }; diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 29bb63933..c6a25f781 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -2188,6 +2188,11 @@ std::expected phase13_finish(PrepareState& state) { ctx.runtimeBinding = state.targetRuntimeBinding ? *state.targetRuntimeBinding : state.runtimeBindingSnapshot; ctx.runUnder = state.runUnder; + for (auto const& p : state.packages) { + ctx.packageRoots.try_emplace(p.manifest.package.name, p.root); + if (!p.manifest.package.namespace_.empty()) + ctx.packageRoots.try_emplace(p.manifest.package.namespace_ + "." + p.manifest.package.name, p.root); + } { auto const& c = state.targetChoice; auto& s = ctx.targetSelection; diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index 0ce3f90cd..21f7730e1 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -863,7 +863,8 @@ struct PrepareState { std::function, std::string>(const std::string&, const std::string&, const std::string&, const std::string&, - const std::vector&, const std::string&)> + const std::vector&, const std::string&, + const std::string&)> loadVersionDepVariant; std::map resolved; diff --git a/src/cli.cppm b/src/cli.cppm index 7f2e81f6a..61e413c6c 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -19,6 +19,7 @@ import mcpp.build.schedule.detach_codegen; import mcpplibs.cmdline; import mcpp.cli.cmd_build; import mcpp.cli.cmd_cache; +import mcpp.cli.cmd_patch; import mcpp.cli.cmd_new; import mcpp.cli.cmd_publish; import mcpp.cli.cmd_xpkg; @@ -75,6 +76,7 @@ void print_usage() { std::println(" mcpp clean [--stale] [--bmi-cache] Remove target/ (or, with --stale, only its non-current fingerprint dirs)"); std::println(" mcpp add [ns.]pkg@ver Add an exact dependency to mcpp.toml"); std::println(" mcpp remove [ns.]pkg Remove an exact dependency from mcpp.toml"); + std::println(" mcpp patch edit|save Edit a dependency's sources; save the change as its patch"); std::println(" mcpp update [pkg] Re-resolve deps and rewrite mcpp.lock"); std::println(" mcpp search Search packages in registries"); std::println(" mcpp publish [--dry-run] Publish package to default registry"); @@ -1221,6 +1223,20 @@ int run(int argc, char** argv) { .option(cl::Option("target").takes_value().help( "Remove the payload for instead of the host one"))) .action(wrap_rc(cmd_toolchain_any))) + .subcommand(cl::App("patch") + .description("Edit a dependency's sources and save the change as its patch") + .subcommand(cl::App("edit") + .description("Copy the dependency's package root, as this build reads it, to target/patch/") + .arg(cl::Arg("dependency").help("The dependency, as [dependencies] names it").required())) + .subcommand(cl::App("save") + .description("Write the edit as the dependency's next patch under patches/ and add it to the edge") + .arg(cl::Arg("dependency").help("The dependency, as [dependencies] names it").required())) + .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { + return dispatch_sub("patch", p, { + {"edit", cmd_patch_edit}, + {"save", cmd_patch_save}, + }); + }))) .subcommand(cl::App("cache") .description("Inspect and manage the global build cache") .subcommand(cl::App("dir") @@ -1924,7 +1940,7 @@ int run(int argc, char** argv) { static constexpr std::array known = std::to_array({ "new", "build", "run", "test", "clean", "add", "remove", "update", "search", "publish", "pack", "emit", "xpkg", - "toolchain", "cache", "index", "self", "explain", + "toolchain", "cache", "patch", "index", "self", "explain", "version", "dyndep", "why", "resolve", "stage", "bmi-equal", "coff-def", "place-dlls", "link-output", "platform-step", "bmi-compile", "bmi-supervise", "bmi-await", diff --git a/src/cli/cmd_patch.cpp b/src/cli/cmd_patch.cpp new file mode 100644 index 000000000..d627416c7 --- /dev/null +++ b/src/cli/cmd_patch.cpp @@ -0,0 +1,147 @@ +// mcpp.cli.cmd_patch — the implementation. An implementation unit, so that the +// modules it reads are not reachable from the CLI's interface: imported by the +// interface, GCC 16.1 ended with an internal compiler error at `import mcpp.cli;` +// in main.cpp (measured 2026-11-09, as for mcpp.build.flag_origins). +module mcpp.cli.cmd_patch; + +import std; +import mcpplibs.cmdline; +import mcpp.build.prepare; +import mcpp.manifest; +import mcpp.project; +import mcpp.pm.patch; +import mcpp.ui; + +namespace mcpp::cli { + +namespace { + +namespace fs = std::filesystem; + +struct Edge { + std::string key; + mcpp::manifest::DependencySpec spec; +}; + +// The root's edge to `name`: its key, or its short name. +std::optional edge_of(const mcpp::manifest::Manifest& m, std::string_view name) { + for (auto const& [key, spec] : m.dependencies) + if (key == name || spec.shortName == name) return Edge{ key, spec }; + return std::nullopt; +} + +fs::path edit_dir(const fs::path& project, std::string_view key) { + return project / "target" / "patch" / std::string(key); +} + +fs::path base_dir(const fs::path& project, std::string_view key) { + return project / "target" / "patch" / (std::string(key) + ".base"); +} + +int fail(std::string_view text) { + mcpp::ui::error(text); + return 2; +} + +} // namespace + +int cmd_patch_edit(const mcpplibs::cmdline::ParsedArgs& parsed) { + const std::string name = parsed.positional(0); + if (name.empty()) return fail("mcpp patch edit needs a dependency: mcpp patch edit "); + auto ctx = mcpp::build::prepare_build(false, false, {}, {}); + if (!ctx) return fail(ctx.error()); + auto edge = edge_of(ctx->manifest, name); + if (!edge) return fail(std::format("'{}' is not a dependency of {}", name, ctx->manifest.package.name)); + if (edge->spec.isPath()) + return fail(std::format("'{}' is a path dependency: its tree is the project's to edit", name)); + // The package by its name, or a patched variant's (`+p`). + auto root = std::ranges::find_if(ctx->packageRoots, [&](auto const& e) { + for (auto const& n : { edge->spec.shortName, edge->spec.namespace_ + "." + edge->spec.shortName }) + if (e.first == n || e.first.starts_with(n + "+p")) return true; + return false; + }); + if (root == ctx->packageRoots.end()) return fail(std::format("the build did not read '{}'", name)); + const auto work = edit_dir(ctx->projectRoot, edge->key), base = base_dir(ctx->projectRoot, edge->key); + std::error_code ec; + if (fs::exists(work, ec)) + return fail(std::format("'{}' is being edited at {}: `mcpp patch save {}` writes its patch", name, + work.string(), name)); + fs::create_directories(work.parent_path(), ec); + const auto real = fs::weakly_canonical(root->second, ec); + for (auto const& to : { base, work }) { + fs::copy(real, to, fs::copy_options::recursive, ec); + if (ec) return fail(std::format("cannot copy {} to {}: {}", real.string(), to.string(), ec.message())); + } + mcpp::ui::info("Editing", std::format("{} at {}", name, work.string())); + mcpp::ui::line(std::format(" the tree is the package's root, its {} patch(es) applied; " + "`mcpp patch save {}` writes the change", edge->spec.patches.size(), name)); + return 0; +} + +int cmd_patch_save(const mcpplibs::cmdline::ParsedArgs& parsed) { + const std::string name = parsed.positional(0); + if (name.empty()) return fail("mcpp patch save needs a dependency: mcpp patch save "); + auto project = mcpp::project::find_manifest_root(fs::current_path()); + if (!project) return fail("no mcpp.toml in this directory or its parents"); + const auto manifestPath = *project / "mcpp.toml"; + auto m = mcpp::manifest::load(manifestPath); + if (!m) return fail(m.error().format()); + auto edge = edge_of(*m, name); + if (!edge) return fail(std::format("'{}' is not a dependency of {}", name, m->package.name)); + const auto work = edit_dir(*project, edge->key), base = base_dir(*project, edge->key); + std::error_code ec; + if (!fs::is_directory(work, ec) || !fs::is_directory(base, ec)) + return fail(std::format("'{}' is not being edited: `mcpp patch edit {}` begins", name, name)); + const auto diff = mcpp::pm::patch::diff_trees(base, work); + if (diff.empty()) { + mcpp::ui::info("Unchanged", std::format("{}: the tree is as it was; no patch is written", name)); + return 0; + } + // The next patch of the edge, named after the package and its place in the set. + const auto stem = edge->spec.shortName.empty() ? edge->key : edge->spec.shortName; + fs::path rel; + for (std::size_t n = edge->spec.patches.size() + 1;; ++n) { + rel = fs::path("patches") / std::format("{}-{:04}.patch", stem, n); + if (!fs::exists(*project / rel, ec)) break; + } + fs::create_directories((*project / rel).parent_path(), ec); + { + std::ofstream out(*project / rel, std::ios::binary); + out << diff; + if (!out) return fail(std::format("cannot write {}", (*project / rel).string())); + } + std::vector patches; + for (auto const& p : edge->spec.patches) + patches.push_back(fs::path(p).lexically_relative(*project).generic_string()); + patches.push_back(rel.generic_string()); + bool recorded = false; + if (!edge->spec.isGit() && !edge->spec.version.empty()) { + std::ifstream in(manifestPath, std::ios::binary); + const std::string text{std::istreambuf_iterator(in), {}}; + auto edited = mcpp::manifest::upsert_dependency_text(text, { + .namespace_ = edge->spec.namespace_.empty() ? std::string("mcpplibs") : edge->spec.namespace_, + .shortName = edge->spec.shortName, + .version = edge->spec.version, + .features = edge->spec.features, + .patches = patches, + }); + if (edited) { + std::ofstream out(manifestPath, std::ios::binary | std::ios::trunc); + out << *edited; + recorded = bool(out); + } + } + fs::remove_all(work, ec); + fs::remove_all(base, ec); + mcpp::ui::info("Saved", std::format("{} ({} bytes)", rel.generic_string(), diff.size())); + if (recorded) { + mcpp::ui::line(std::format(" mcpp.toml: {} patches = [{} file(s)]", edge->key, patches.size())); + } else { + std::string list; + for (auto const& p : patches) list += std::format("{}\"{}\"", list.empty() ? "" : ", ", p); + mcpp::ui::line(std::format(" add it to the edge in mcpp.toml: patches = [{}]", list)); + } + return 0; +} + +} // namespace mcpp::cli diff --git a/src/cli/cmd_patch.cppm b/src/cli/cmd_patch.cppm new file mode 100644 index 000000000..0c902f0ed --- /dev/null +++ b/src/cli/cmd_patch.cppm @@ -0,0 +1,20 @@ +// mcpp.cli.cmd_patch — `mcpp patch edit ` and `mcpp patch save +// ` (mcpp#911 G8; docs/05 §"Patching a dependency"). +// +// `edit` copies the dependency's package root as this build reads it (its +// existing patches applied) to `target/patch//`, beside a copy of +// the same tree it is compared with. `save` writes the difference as the next +// patch of the edge, under `patches/`, named from the package's root, and adds +// it to the edge's `patches`; the two copies are then removed. + +export module mcpp.cli.cmd_patch; + +import std; +import mcpplibs.cmdline; + +export namespace mcpp::cli { + +int cmd_patch_edit(const mcpplibs::cmdline::ParsedArgs& parsed); +int cmd_patch_save(const mcpplibs::cmdline::ParsedArgs& parsed); + +} // namespace mcpp::cli diff --git a/src/pm/patch.cppm b/src/pm/patch.cppm index 309856518..a78a7358e 100644 --- a/src/pm/patch.cppm +++ b/src/pm/patch.cppm @@ -37,10 +37,24 @@ std::expected apply(const std::filesystem::path& root, // The variant's tree: `source` copied to `variant` and the patches applied in // order, unless `variant` already holds the result for this digest. Written // beside `variant` and renamed into place, so a tree that exists is whole. +// +// THE PATCH ROOT IS THE PACKAGE'S (mcpp#911 G8): a patch names files relative +// to `patchRoot`, the directory of the package's resolved manifest (or the +// dependency's `patch_root`), relative to `source`. A patch written against +// `source` itself -- the store's version directory, as before -- still +// applies there, and `notes` receives that it did. std::expected materialize(const std::filesystem::path& source, const std::vector& patches, std::string_view digest, - const std::filesystem::path& variant); + const std::filesystem::path& variant, + const std::filesystem::path& patchRoot = {}, + std::vector* notes = nullptr); + +// THE DIFFERENCE OF TWO TREES AS A PATCH (`mcpp patch save`): a unified diff of +// every file that differs, is added or is removed under `after` against +// `before`, the paths relative to the trees (`a/`, `b/`), three +// lines of context. Empty when the trees are the same. +std::string diff_trees(const std::filesystem::path& before, const std::filesystem::path& after); // The directory name of a variant of `version`: `+p`. Short, because Windows paths have a length. @@ -303,7 +317,9 @@ std::expected apply(const fs::path& root, std::string_view te std::expected materialize(const fs::path& source, const std::vector& patches, std::string_view digest, - const fs::path& variant) { + const fs::path& variant, + const fs::path& patchRoot, + std::vector* notes) { const fs::path marker = variant / ".mcpp-variant"; std::error_code ec; if (auto have = read_bytes(marker); have && *have == std::string(digest) + "\n") { @@ -331,7 +347,24 @@ std::expected materialize(const fs::path& source, for (auto const& p : patches) { auto text = read_bytes(p); if (!text) { fs::remove_all(work, ec); return std::unexpected(std::format("the patch '{}' cannot be read", p)); } - if (auto r = apply(work, *text, fs::path(p).filename().string()); !r) { + // The package's root, unless the patch names its first file from the + // source's own top and not from there (a patch written before G8). + fs::path root = work / patchRoot; + if (!patchRoot.empty()) { + if (auto parsed = parse(*text); parsed && !parsed->empty()) { + const auto& first = parsed->front(); + const std::string rel = first.oldPath.empty() ? first.newPath : first.oldPath; + if (!first.oldPath.empty() && !fs::exists(root / rel, ec) && fs::exists(work / rel, ec)) { + root = work; + if (notes) + notes->push_back(std::format( + "the patch '{}' names its files from the archive's top ('{}'); a patch names " + "them from the package's root ('{}'), and this one is read as written", + fs::path(p).filename().string(), rel, patchRoot.generic_string())); + } + } + } + if (auto r = apply(root, *text, fs::path(p).filename().string()); !r) { fs::remove_all(work, ec); return std::unexpected(std::format("{} (from {})", r.error(), p)); } @@ -353,4 +386,132 @@ std::expected materialize(const fs::path& source, return {}; } +namespace { + +// The lines of a file, without their newlines, and whether the last had one. +struct text_lines { + std::vector lines; + bool lastNewline = true; +}; + +text_lines lines_of(const std::string& bytes) { + text_lines t; + std::size_t at = 0; + while (at < bytes.size()) { + const auto nl = bytes.find('\n', at); + if (nl == std::string::npos) { + t.lines.push_back(bytes.substr(at)); + t.lastNewline = false; + break; + } + t.lines.push_back(bytes.substr(at, nl - at)); + at = nl + 1; + } + return t; +} + +// The longest common subsequence of `a` and `b` as pairs of indices, by the +// table of suffix lengths: files a patch edits are small. +std::vector> common(const std::vector& a, + const std::vector& b) { + const std::size_t n = a.size(), m = b.size(); + std::vector len((n + 1) * (m + 1), 0); + auto at = [&](std::size_t i, std::size_t j) -> std::uint32_t& { return len[i * (m + 1) + j]; }; + for (std::size_t i = n; i-- > 0;) + for (std::size_t j = m; j-- > 0;) + at(i, j) = a[i] == b[j] ? at(i + 1, j + 1) + 1 : std::max(at(i + 1, j), at(i, j + 1)); + std::vector> out; + std::size_t i = 0, j = 0; + while (i < n && j < m) { + if (a[i] == b[j]) { out.emplace_back(i++, j++); continue; } + if (at(i + 1, j) >= at(i, j + 1)) ++i; else ++j; + } + return out; +} + +// One file's unified diff; `rel` its path, empty `before`/`after` path for an +// added or removed file. +std::string diff_file(const std::string& rel, const std::optional& before, + const std::optional& after) { + const auto a = lines_of(before.value_or(std::string{})); + const auto b = lines_of(after.value_or(std::string{})); + // The edit script: each entry is (kind, line of a, line of b); ' ', '-', '+'. + struct op { char kind; std::size_t ai, bi; }; + std::vector script; + std::size_t i = 0, j = 0; + for (auto [ci, cj] : common(a.lines, b.lines)) { + while (i < ci) script.push_back({'-', i++, j}); + while (j < cj) script.push_back({'+', i, j++}); + script.push_back({' ', i++, j++}); + } + while (i < a.lines.size()) script.push_back({'-', i++, j}); + while (j < b.lines.size()) script.push_back({'+', i, j++}); + std::string out = std::format("--- {}\n+++ {}\n", before ? "a/" + rel : std::string("/dev/null"), + after ? "b/" + rel : std::string("/dev/null")); + constexpr std::size_t kContext = 3; + std::size_t k = 0; + while (k < script.size()) { + if (script[k].kind == ' ') { ++k; continue; } + // A hunk: from the change back by the context, forward while changes + // come within twice the context of each other. + std::size_t begin = k >= kContext ? k - kContext : 0; + while (begin < k && script[begin].kind != ' ') ++begin; + std::size_t end = k, quiet = 0; + for (std::size_t q = k; q < script.size(); ++q) { + if (script[q].kind == ' ') { if (++quiet > 2 * kContext) break; } + else { quiet = 0; end = q; } + } + const std::size_t stop = std::min(script.size(), end + 1 + kContext); + std::size_t oldCount = 0, newCount = 0; + for (std::size_t q = begin; q < stop; ++q) { + if (script[q].kind != '+') ++oldCount; + if (script[q].kind != '-') ++newCount; + } + const std::size_t oldStart = oldCount ? script[begin].ai + 1 : script[begin].ai; + const std::size_t newStart = newCount ? script[begin].bi + 1 : script[begin].bi; + out += std::format("@@ -{},{} +{},{} @@\n", oldStart, oldCount, newStart, newCount); + for (std::size_t q = begin; q < stop; ++q) { + const auto& o = script[q]; + const bool fromA = o.kind != '+'; + const auto& text = fromA ? a.lines[o.ai] : b.lines[o.bi]; + out += o.kind; + out += text; + out += '\n'; + const bool last = fromA ? o.ai + 1 == a.lines.size() && !a.lastNewline + : o.bi + 1 == b.lines.size() && !b.lastNewline; + if (last) out += "\\ No newline at end of file\n"; + } + k = stop; + } + return out; +} + +std::set files_under(const fs::path& root) { + std::set out; + std::error_code ec; + if (!fs::is_directory(root, ec)) return out; + for (auto it = fs::recursive_directory_iterator(root, ec); !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { + if (!it->is_regular_file(ec)) continue; + auto rel = fs::relative(it->path(), root, ec).generic_string(); + if (rel == ".mcpp-variant" || rel.starts_with(".mcpp-patch")) continue; + out.insert(std::move(rel)); + } + return out; +} + +} // namespace + +std::string diff_trees(const fs::path& before, const fs::path& after) { + auto all = files_under(before); + all.merge(files_under(after)); + std::string out; + for (auto const& rel : all) { + auto a = read_bytes(before / rel); + auto b = read_bytes(after / rel); + if (a && b && *a == *b) continue; + out += diff_file(rel, a, b); + } + return out; +} + } // namespace mcpp::pm::patch diff --git a/tests/e2e/1046_a_dependency_is_edited_and_its_change_saved_as_a_patch.sh b/tests/e2e/1046_a_dependency_is_edited_and_its_change_saved_as_a_patch.sh new file mode 100755 index 000000000..065bcb400 --- /dev/null +++ b/tests/e2e/1046_a_dependency_is_edited_and_its_change_saved_as_a_patch.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# requires: elf gcc +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1046 -- a dependency is edited and its change saved as its patch (mcpp#911 G8; docs/05). +# +# `mcpp patch edit ` copies the dependency's package root, as the build reads it, to +# target/patch//. `mcpp patch save ` writes the difference as the edge's +# next patch under patches/, named from the package's root, and adds it to the edge; the build +# then reads the patched variant. +# +# Criteria: the patch names the edited file from the package's root; mcpp.toml's edge lists it; +# the build succeeds and its variant tree holds the change; a second save without a change writes +# nothing. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +mkdir -p "$t/src" +cat > "$t/mcpp.toml" <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[dependencies] +mcpplibs.cmdline = "0.0.1" +EOF +echo 'int main() { return 0; }' > "$t/src/main.cpp" +cd "$t" + +out=$("$MCPP" patch edit cmdline 2>&1) || fail "patch edit failed" "$out" +grep -q "Editing cmdline at .*target/patch/mcpplibs.cmdline" <<<"$out" || fail "the edit tree is not reported" "$out" +file=$(find target/patch/mcpplibs.cmdline -name parse.cppm | head -1) +[ -n "$file" ] || fail "the edit tree holds no parse.cppm" "$(find target/patch -maxdepth 3)" +rel=${file#target/patch/mcpplibs.cmdline/} +echo '// changed through mcpp patch' >> "$file" + +out=$("$MCPP" patch save cmdline 2>&1) || fail "patch save failed" "$out" +grep -q "Saved patches/cmdline-0001.patch" <<<"$out" || fail "the patch is not saved" "$out" +grep -q -- "--- a/$rel" patches/cmdline-0001.patch || fail "the patch does not name the file from the package root" "$(head -3 patches/cmdline-0001.patch)" +grep -q 'patches = \["patches/cmdline-0001.patch"\]' mcpp.toml || fail "the edge does not list the patch" "$(cat mcpp.toml)" +[ ! -e target/patch/mcpplibs.cmdline ] || fail "the edit tree is left behind" +echo " ok the change is saved as the edge's patch, named from the package root" + +out=$("$MCPP" build 2>&1) || fail "the patched build failed" "$out" +home="${MCPP_HOME:-$HOME/.mcpp}" +grep -rqs 'changed through mcpp patch' "$home"/cache/src/mcpplibs.cmdline/*/"$rel" || fail "no variant holds the change" +echo " ok the build reads the patched variant" + +"$MCPP" patch edit cmdline >/dev/null 2>&1 || fail "the second edit failed" +out=$("$MCPP" patch save cmdline 2>&1) || fail "the second save failed" "$out" +grep -q "Unchanged" <<<"$out" || fail "a save without a change wrote something" "$out" +[ ! -e patches/cmdline-0002.patch ] || fail "a second patch was written" +echo " ok a save without a change writes nothing" +echo "PASS: 1046 a dependency is edited and its change saved as a patch" diff --git a/tests/unit/test_patch.cpp b/tests/unit/test_patch.cpp index da447378c..8c0b3ac0a 100644 --- a/tests/unit/test_patch.cpp +++ b/tests/unit/test_patch.cpp @@ -161,3 +161,58 @@ UTest a_variant_never_writes_through_a_link{"Patch/AVariantNeverWritesThroughALi }}; } // namespace + +// THE PATCH ROOT IS THE PACKAGE'S (mcpp#911 G8): a patch names files from the +// directory of the package's manifest inside its archive; one written from the +// archive's top, as before, applies as written and is said. +UTest a_patch_names_files_from_the_package_root{"Patch/APatchNamesFilesFromThePackageRoot", [] { + auto base = fresh("root"); + put(base / "src/pkg-1.0/run/mcpp.toml", "[package]\nname = \"run\"\n"); + const auto rooted = base / "rooted.patch", topped = base / "topped.patch"; + put(rooted, "--- a/mcpp.toml\n+++ b/mcpp.toml\n@@ -1,2 +1,3 @@\n [package]\n name = \"run\"\n+version = \"1.0\"\n"); + put(topped, "--- a/pkg-1.0/run/mcpp.toml\n+++ b/pkg-1.0/run/mcpp.toml\n@@ -1,2 +1,3 @@\n [package]\n name = \"run\"\n+version = \"1.0\"\n"); + std::vector notes; + auto r = pt::materialize(base / "src", {rooted.string()}, "d1", base / "v1", "pkg-1.0/run", ¬es); + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error(); + expect(eq(get(base / "v1/pkg-1.0/run/mcpp.toml"), "[package]\nname = \"run\"\nversion = \"1.0\"\n")); + expect(notes.empty()); + auto t = pt::materialize(base / "src", {topped.string()}, "d2", base / "v2", "pkg-1.0/run", ¬es); + if (auto&& failed = expect(fatal(t.has_value())); !failed) failed << t.error(); + expect(eq(get(base / "v2/pkg-1.0/run/mcpp.toml"), "[package]\nname = \"run\"\nversion = \"1.0\"\n")); + expect(fatal(eq(notes.size(), 1u))); + expect(ne(notes[0].find("from the archive's top"), std::string::npos)) << notes[0]; + fs::remove_all(base); +}}; + +// `mcpp patch save`: the difference of two trees is a patch that turns the +// first into the second -- changed, added and removed files, and a last line +// without a newline. +UTest the_difference_of_two_trees_is_a_patch_that_makes_one_the_other{"Patch/TheDifferenceOfTwoTreesIsAPatchThatMakesOneTheOther", [] { + auto base = fresh("diff"); + std::string longText; + for (int i = 1; i <= 30; ++i) longText += std::format("line {}\n", i); + put(base / "before/src/a.c", longText); + put(base / "before/gone.txt", "bye\n"); + put(base / "before/tail.txt", "no newline"); + std::string changed = longText; + changed.replace(changed.find("line 3\n"), 7, "line three\n"); + changed.replace(changed.find("line 25\n"), 8, ""); + changed += "line 31\n"; + put(base / "after/src/a.c", changed); + put(base / "after/new/b.h", "#pragma once\n"); + put(base / "after/tail.txt", "no newline, still"); + const auto diff = pt::diff_trees(base / "before", base / "after"); + expect(ne(diff.find("--- a/src/a.c\n+++ b/src/a.c\n"), std::string::npos)) << diff; + expect(ne(diff.find("--- /dev/null\n+++ b/new/b.h\n"), std::string::npos)) << diff; + expect(ne(diff.find("--- a/gone.txt\n+++ /dev/null\n"), std::string::npos)) << diff; + // Applied to a copy of the first tree, it gives the second. + fs::copy(base / "before", base / "check", fs::copy_options::recursive); + auto r = pt::apply(base / "check", diff, "saved.patch"); + if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error() << "\n" << diff; + expect(eq(get(base / "check/src/a.c"), changed)); + expect(eq(get(base / "check/new/b.h"), "#pragma once\n")); + expect(eq(get(base / "check/tail.txt"), "no newline, still")); + expect(!fs::exists(base / "check/gone.txt")); + expect(pt::diff_trees(base / "after", base / "check").empty()); + fs::remove_all(base); +}}; From 17c929d54be3dba6fd090881a4de67d949f19813 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 08:46:31 +0900 Subject: [PATCH 16/45] an artifact compiles the shared sources it links with its own flags (mcpp#911 G9) `[targets..build]` states defines and flags for every translation unit of the package the artifact links; where they differ, the units are compiled as the artifact's variant under obj/_variant//, and the other artifacts link the package's objects. Module interfaces stay one per package. The existing per-target keys keep their entry scope. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- docs/04-mcpp-toml.md | 4 +- docs/zh/04-mcpp-toml.md | 1 + modules/manifest/src/toml.cppm | 25 +++++++- modules/manifest/src/types.cppm | 11 ++++ src/build/plan.cppm | 34 +++++++++++ ...s_its_shared_sources_with_its_own_flags.sh | 60 +++++++++++++++++++ 6 files changed, 133 insertions(+), 2 deletions(-) create mode 100755 tests/e2e/1047_an_artifact_compiles_its_shared_sources_with_its_own_flags.sh diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index f8d6e28b2..2a1d3a65f 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -506,6 +506,7 @@ required_features = ["gui"] # only built when feature `gui` is |---|---| | `defines` | Preprocessor macros (`name` or `name=value`); desugar to `-D` on both the C and C++ entry compile. | | `cxxflags` / `cflags` | Extra compile flags for this target. Do **not** put `-std=...` here — use `[package].standard`. | +| `build` *(2026.11.9.1+)* | A table, `[targets..build]` with `defines`, `cxxflags` and `cflags`: the flags of every translation unit of this package the target links — its entry and the shared implementation sources. Where they differ from the package's, the units are compiled as the target's own variant (`obj/_variant//`); the other targets link the package's objects. A module interface is one per package and is not varied. | | `required_features` | The target is emitted only when **every** listed feature is active in the build; otherwise it is silently skipped. A gate only — it does not activate features (use `--features` / `[features].default`). **One exception, and it is not a second rule:** when this target is requested as a host tool (`tools = [...]`, §2.14), the target is what was *asked for*, so its `required_features` become the sub-build's *inputs*. Same field, one meaning — the resolution just runs in the opposite direction. | | `windows_subsystem` *(2026.9.12.2+)* | The PE subsystem of an executable: `"console"` (the default) or `"windows"`, a GUI program that starts without a console. Reaches this target's link and no other, and renders nothing on a target that is not PE. See the section above. | | `windows_entry` *(2026.9.12.2+)* | The entry function the program defines: `"main"` (the default), `"wmain"`, `"WinMain"` or `"wWinMain"`. See the section above. An entry target states the function its translation unit defines, and the program that selects it inherits it. | @@ -519,7 +520,8 @@ required_features = ["gui"] # only built when feature `gui` is > **Scope (important):** `defines` / `cxxflags` / `cflags` on a target apply **only to that > target's exclusive entry source** (its `main`) — never to shared module/impl objects, which -> are compiled once and linked into every target (mcpp's compile-once model). They are the right +> are compiled once and linked into every target (mcpp's compile-once model); the shared sources +> of one target take its own flags through `[targets..build]`. They are the right > tool when the flag only needs to affect a single binary's (or test's) own entry — for example a > per-test contract evaluation semantic (`-fcontract-evaluation-semantic=observe`) for a test whose > `main` exercises the violation, a feature macro the entry alone reads, or a local warning diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index c2df23c3f..4fd5a8312 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -478,6 +478,7 @@ required_features = ["gui"] # only built when feature `gui` is |---|---| | `defines` | 预处理宏(`name` 或 `name=value`);在 C 与 C++ 两种入口编译上都脱糖为 `-D`。 | | `cxxflags` / `cflags` | 这个目标专用的额外编译旗标。**不要**把 `-std=...` 写在这里——用 `[package].standard`。 | +| `build` *(2026.11.9.1+)* | 一个表 `[targets..build]`,含 `defines`、`cxxflags`、`cflags`:本包中该目标链接的每个翻译单元的参数,即其入口与共享的实现源文件。与包的参数不同处,这些单元编译为该目标自己的变体(`obj/_variant//`);其他目标照旧链接包的目标文件。模块接口每包一个,不做变体。 | | `required_features` | 只有当构建中**每一个**列出的 feature 都被激活时,这个目标才会被产出;否则被静默跳过。它只是一道闸——不会激活 feature(用 `--features` / `[features].default`)。**一个例外,但它不是第二条规则:** 当这个目标作为 host 工具被请求时(`tools = [...]`,§2.14),这个目标就是被**请求**的那一个,于是它的 `required_features` 变成子构建的**输入**。同一个字段、同一个含义——只是解析的方向反过来了。 | | `windows_subsystem` *(2026.9.12.2+)* | 可执行文件的 PE subsystem:`"console"`(默认)或 `"windows"`,一个启动时没有控制台的 GUI 程序。只到达这个目标的链接,别处不受影响,在非 PE 的目标上不渲染任何东西。见上一节。 | | `windows_entry` *(2026.9.12.2+)* | 程序定义的入口函数:`"main"`(默认)、`"wmain"`、`"WinMain"` 或 `"wWinMain"`。见上一节。入口目标陈述其翻译单元定义的函数,由选择它的程序继承。 | diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 77355de26..29d472673 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -2187,6 +2187,29 @@ std::expected parse_document(const t::Document& documen read_list("cxxflags", t.cxxflags); read_list("defines", t.defines); read_list("required_features", t.requiredFeatures); + // `[targets..build]`: the artifact's own compile of the + // package's translation units it links (mcpp#911 G9). + if (auto it = tt.find("build"); it != tt.end()) { + if (!it->second.is_table()) + return std::unexpected(error(origin, std::format( + "[targets.{}].build must be a table of cflags, cxxflags and defines", tname))); + for (auto const& [bk, bv] : it->second.as_table()) { + std::vector* dst = bk == "cflags" ? &t.buildCflags + : bk == "cxxflags" ? &t.buildCxxflags + : bk == "defines" ? &t.buildDefines : nullptr; + if (!dst || !bv.is_array()) + return std::unexpected(error(origin, std::format( + "[targets.{}.build].{} is not read: the keys are cflags, cxxflags and defines, " + "each a list", tname, bk))); + for (auto const& v : bv.as_array()) + if (v.is_string()) dst->push_back(v.as_string()); + } + for (auto const& flag : t.buildCxxflags) + if (starts_with_std_flag(flag)) + return std::unexpected(error(origin, std::format( + "[targets.{}.build].cxxflags contains '{}'; use [package].standard to " + "configure the C++ language standard", tname, flag))); + } // `windows_subsystem` / `windows_entry` (#618). A closed set each, so a // misspelling is refused by name rather than rendered as nothing. auto read_choice = [&](const char* key, std::string& out, bool subsystem) @@ -2274,7 +2297,7 @@ std::expected parse_document(const t::Document& documen // at the right axis (workspace / features / profile). static constexpr std::string_view kKnownTargetKeys[] = { "kind", "linkage", "main", "soname", "exports", "windows_auto_export", - "cflags", "cxxflags", "defines", "required_features", + "cflags", "cxxflags", "defines", "required_features", "build", "windows_entry", "windows_subsystem", "windows_code_page", "windows_long_paths", "start", "test-protocol", "test-main", "test-guide", }; diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 5ac8fb9d7..9927e0190 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -290,6 +290,17 @@ struct Target { std::vector cflags; std::vector cxxflags; std::vector defines; + // `[targets..build]` (mcpp#911 G9): flags and defines of every + // translation unit of this package the artifact links -- its entry and the + // shared implementation sources -- compiled as the artifact's own variant + // where they differ from the package's. A module interface stays one per + // package and is not varied. + std::vector buildCflags; + std::vector buildCxxflags; + std::vector buildDefines; + bool has_build_variant() const { + return !buildCflags.empty() || !buildCxxflags.empty() || !buildDefines.empty(); + } // Build gate: this target is emitted ONLY when every listed feature is // active in the current build (otherwise it is silently skipped). Gate // only — it does not activate features (use --features / [features].default). diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 7d4b44d53..0c5c31880 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -1932,6 +1932,9 @@ make_plan(const mcpp::manifest::Manifest& manifest, const mcpp::platform::Resolved* platform) { BuildPlan plan; + // The artifacts' own compiles of shared sources (mcpp#911 G9), joined to + // the plan's units after every link unit is formed. + std::vector artifactVariants; plan.manifest = manifest; plan.toolchain = tc; plan.fingerprint = fp; @@ -3800,6 +3803,25 @@ make_plan(const mcpp::manifest::Manifest& manifest, // Also include implementation .cpp/.cc/.cxx/.c units, but EXCLUDE any // file registered as another target's entryMain (each binary's main() // is exclusive to that binary). + // THE ARTIFACT'S OWN COMPILE (mcpp#911 G9): `[targets..build]` + // makes a variant of each of the package's implementation units this + // artifact links, under `obj/_variant//`, with the artifact's flags + // after the package's; the entry takes them in place. Module + // interfaces are one per package and are not varied. + const auto rootPackage = qualified_package_name(manifest); + auto vary = [&](CompileUnit& cu) { + for (auto const& d : t.buildDefines) { + const auto element = mcpp::manifest::flag_element("-D" + d); + cu.packageCflags.push_back(element); + cu.packageCxxflags.push_back(element); + } + for (auto const& f : t.buildCflags) cu.packageCflags.push_back(f); + for (auto const& f : t.buildCxxflags) cu.packageCxxflags.push_back(f); + }; + std::vector variants; + if (t.has_build_variant() && lu.entryMain) + for (auto& cu : plan.compileUnits) + if (cu.source == *lu.entryMain && cu.packageName == rootPackage) vary(cu); for (auto& cu : plan.compileUnits) { if (sharedDepPackages.contains(cu.packageName)) continue; if (placedInImage.contains(cu.packageName)) continue; @@ -3813,8 +3835,19 @@ make_plan(const mcpp::manifest::Manifest& manifest, // it supplies the entry (gtest-style). Works on every linker — the // object is linked directly, never relying on archive member pulling. if (entryDefinesMain && depMainSources.contains(cu.source)) continue; + if (t.has_build_variant() && cu.packageName == rootPackage && cu.providesModule.empty()) { + CompileUnit v = cu; + v.object = std::filesystem::path("obj") / "_variant" / sanitize(t.name) + / cu.object.lexically_relative("obj"); + vary(v); + lu.objects.push_back(v.object); + variants.push_back(std::move(v)); + continue; + } lu.objects.push_back(cu.object); } + // Added once every link unit is formed: no other artifact links them. + for (auto& v : variants) artifactVariants.push_back(std::move(v)); if (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary || lu.kind == LinkUnit::SharedLibrary) { @@ -4528,6 +4561,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, }); } } + for (auto& v : artifactVariants) plan.compileUnits.push_back(std::move(v)); return plan; } diff --git a/tests/e2e/1047_an_artifact_compiles_its_shared_sources_with_its_own_flags.sh b/tests/e2e/1047_an_artifact_compiles_its_shared_sources_with_its_own_flags.sh new file mode 100755 index 000000000..4e1c01e78 --- /dev/null +++ b/tests/e2e/1047_an_artifact_compiles_its_shared_sources_with_its_own_flags.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# requires: elf gcc +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1047 -- an artifact compiles the shared sources it links with its own flags (mcpp#911 G9). +# +# `[targets..build]` states defines and flags for every translation unit of the package the +# artifact links: its entry and the shared implementation sources, compiled as the artifact's own +# variant under obj/_variant// where they differ. The other artifacts link the package's +# objects as before; a module interface stays one per package. +# +# Criteria: the program with the define returns the shared source's defined value (5), the other +# returns the plain one (4); a variant object exists for the varied artifact only; an +# implementation unit that imports the package's module builds in both. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +mkdir -p "$t/src" +cat > "$t/mcpp.toml" <<'EOF' +[package] +name = "shell" +version = "0.1.0" + +[targets.cmd] +kind = "bin" +main = "src/cmd.cpp" + +[targets.COMMAND] +kind = "bin" +main = "src/command.cpp" + +[targets.COMMAND.build] +defines = ["ENABLE_AUTOEXEC=1"] +EOF +cat > "$t/src/base.cppm" <<'EOF' +export module shell.base; +export int base_value() { return 4; } +EOF +cat > "$t/src/shell.cpp" <<'EOF' +import shell.base; +int autoexec() { +#ifdef ENABLE_AUTOEXEC + return base_value() + 1; +#else + return base_value(); +#endif +} +EOF +echo 'int autoexec(); int main() { return autoexec(); }' > "$t/src/cmd.cpp" +echo 'int autoexec(); int main() { return autoexec(); }' > "$t/src/command.cpp" +cd "$t" + +out=$("$MCPP" build 2>&1) || fail "the build failed" "$out" +dir=$(dirname "$(find target -path '*/bin/cmd' -type f | head -1)") +set +e; "$dir/cmd"; a=$?; "$dir/COMMAND"; b=$?; set -e +[ "$a" = 4 ] || fail "cmd returned $a, not the shared source's plain 4" +[ "$b" = 5 ] || fail "COMMAND returned $b, not 5 from its own define" +[ -n "$(find target -path '*/obj/_variant/COMMAND/*' -name 'shell.o' | head -1)" ] || fail "no variant object of COMMAND" +[ -z "$(find target -path '*/obj/_variant/cmd*' | head -1)" ] || fail "an artifact without flags of its own has a variant" +echo " ok COMMAND compiles the shared source with its define; cmd links the package's object" +echo "PASS: 1047 an artifact compiles its shared sources with its own flags" From 4a3cbe44ad7f2791cb1b7fd5a7d0e6314fb9d60a Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 08:53:25 +0900 Subject: [PATCH 17/45] pack: a row a platform defines is packed and carries as a built-in row does (mcpp#911 G10) - A host pair is any system's word with an architecture; `--systems all` adds the systems the resolved platforms define rows of. - A carrier or a single file for a platform-defined system is built over the platform that defines it. - A static ELF (no loader, no needed image) has no closure and is not run to learn so. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- modules/manifest/src/platform.cppm | 14 ++++++-- modules/manifest/src/platform_toml.cppm | 4 +-- src/pack/pack.cppm | 35 ++++++++++++++++++- tests/e2e/1040_a_platform_defines_a_target.sh | 7 ++++ tests/unit/test_platform.cpp | 10 +++--- 5 files changed, 60 insertions(+), 10 deletions(-) diff --git a/modules/manifest/src/platform.cppm b/modules/manifest/src/platform.cppm index 76784fae3..82c50535a 100644 --- a/modules/manifest/src/platform.cppm +++ b/modules/manifest/src/platform.cppm @@ -203,11 +203,19 @@ struct Carrier { // The `-` vocabulary of `runner.hosts`, `carrier.hosts` and a // package's coverage: a system mcpp builds programs for, and an architecture. +// `-`: a system's word and an architecture's. The system is any a +// row names -- the built-in ones and those a platform defines (mcpp#911 G10); +// so the word is checked for its form here, and for being a system where a +// pair is used. inline bool is_host_pair(std::string_view s) { const auto dash = s.find('-'); - if (dash == std::string_view::npos || dash + 1 == s.size()) return false; - const auto os = s.substr(0, dash); - return (os == "linux" || os == "macos" || os == "windows") + if (dash == 0 || dash == std::string_view::npos || dash + 1 == s.size()) return false; + auto word = [](std::string_view w) { + return std::ranges::all_of(w, [](char c) { + return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; + }); + }; + return word(s.substr(0, dash)) && word(s.substr(dash + 1)) && s.find('-', dash + 1) == std::string_view::npos; } diff --git a/modules/manifest/src/platform_toml.cppm b/modules/manifest/src/platform_toml.cppm index 80ae9b113..e1fc385b1 100644 --- a/modules/manifest/src/platform_toml.cppm +++ b/modules/manifest/src/platform_toml.cppm @@ -502,8 +502,8 @@ Reader::host_pairs(const toml::Value& v, std::string_view where) const { for (auto const& p : *w) if (!mcpp::manifest::platform::is_host_pair(p)) return std::unexpected(std::format( - "{}: '{}' is not a system and an architecture; write \"-\" with " - " one of linux, macos, windows, for example \"linux-x86_64\"", where, p)); + "{}: '{}' is not a system and an architecture; write \"-\", " + "for example \"linux-x86_64\"", where, p)); return w; } diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index c86a54286..b660920f7 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -63,6 +63,8 @@ import mcpp.libs.toml; // a generated manifest quotes its keys (mcpp#9 import mcpp.project; // the effective manifest a build reads import mcpp.build.prepare_inputs; // cfgpred: the target's conditional tables import mcpp.toolchain.triple; +import mcpp.toolchain.target_schema; +import mcpp.toolchain.target_rows; // the rows of a system a platform defines (G10) import mcpp.toolchain.base; import mcpp.ui; import mcpp.runtime.elf; @@ -2370,8 +2372,26 @@ std::string hosted_platform_for(const mcpp::platform::Resolved* P, std::string_v return std::format("{}@{}", P->package, P->version); } +// The platform a row a platform defines is built over, as `--platform` names it. +std::string platform_ref_of(const mcpp::platform::Resolved& P) { + if (!P.selection.ref.spec.path.empty()) return "path:" + P.root.generic_string(); + return std::format("{}@{}", P.package, P.version); +} + ProgramRow program_row(std::string_view system, std::string_view arch, bool single, const mcpp::platform::Resolved* P = nullptr) { + // A SYSTEM A PLATFORM DEFINES (mcpp#911 G10): its row of that architecture, + // built over the platform that defines it, as the platform's carrier and + // packs of every other row are. + if (system != "linux" && system != "macos" && system != "windows") { + if (P && P->active) + for (auto const& d : P->fields.defines) { + auto t = mcpp::toolchain::triple::parse(d.triple); + if (t && t->os == system && t->arch == arch) + return { t->str(), "static", platform_ref_of(*P) }; + } + return { std::format("{}-{}", arch, system), "static", {} }; + } if (system == "linux") { const auto triple = std::format("{}-linux-musl", arch); return { triple, "static", hosted_platform_for(P, triple) }; @@ -2505,6 +2525,7 @@ bool row_is_built(const ProgramRow& row) { if (row.triple.empty()) return true; // this machine's own row auto t = mcpp::toolchain::triple::parse(row.triple); if (!t) return false; + if (t->defined()) return !row.platform.empty(); // a platform's row, over that platform auto* k = mcpp::toolchain::triple::find_known_target(*t); return k && k->tier != "planned"; } @@ -3140,7 +3161,14 @@ run(const Plan& plan, const mcpp::config::GlobalConfig& cfg) " such linker to ask.", mcpp::pack::binfmt::format_name(fmt), plan.binaryName)); } - auto deps = ldd_parse(plan.builtBinary); + // AN ELF WITH NO LOADER AND NO NEEDED IMAGE HAS NO CLOSURE, and is + // not run to learn so: `ldd` of a static program that is not this + // machine's own -- a row a platform defines (mcpp#911 G10) -- + // executes it. + const auto facts = mcpp::runtime::elf::inspect_elf_runtime(plan.builtBinary); + const bool selfContained = facts && facts->interp.empty() && facts->needed.empty(); + auto deps = selfContained ? decltype(ldd_parse(plan.builtBinary)){} + : ldd_parse(plan.builtBinary); if (!deps) return std::unexpected(Error{std::format( "ldd failed on {}: {}", plan.builtBinary.string(), deps.error())}); @@ -3434,6 +3462,11 @@ std::vector systems_for(const std::vector& stated, std for (auto const& e : stated) { if (e == "all") { for (auto os : { "linux", "macos", "windows" }) add(os); + // And every system the platforms of this process define a row of + // for the architecture (mcpp#911 G10). + for (auto const* r : mcpp::toolchain::target::rows()) + if (r->defined && r->arch == arch && !r->os.empty() && r->os != "none") + add(std::string(r->os)); continue; } const auto dash = e.find('-'); diff --git a/tests/e2e/1040_a_platform_defines_a_target.sh b/tests/e2e/1040_a_platform_defines_a_target.sh index 960fa5be8..117b1c982 100644 --- a/tests/e2e/1040_a_platform_defines_a_target.sh +++ b/tests/e2e/1040_a_platform_defines_a_target.sh @@ -111,6 +111,13 @@ grep -q 'the platform toy-platform 0.1.0 names it `{name}.toy`: the platform.s n rm build.mcpp echo " ok the platform's name of a program stands over a build program's" +# A row a platform defines is packed as a built-in row is (mcpp#911 G10); a static program is not +# run to learn its closure. +out=$("$MCPP" pack --target x86_64-toyos 2>&1) || fail "the platform's row was not packed" "$out" +listing=$(tar tzf target/dist/toyapp-0.1.0-x86_64-toyos.tar.gz) +grep -q 'bin/toyapp.toy$' <<<"$listing" || fail "the package holds no program" "$listing" +echo " ok the platform's row is packed" + sed -i '/^\[target.x86_64-toyos\]/,/^platform/d' mcpp.toml out=$("$MCPP" build --target x86_64-toyos 2>&1 || true) grep -q "unknown target 'x86_64-toyos'" <<<"$out" || fail "without the platform the request was not an unknown target" "$out" diff --git a/tests/unit/test_platform.cpp b/tests/unit/test_platform.cpp index b451857a2..5c23fee6d 100644 --- a/tests/unit/test_platform.cpp +++ b/tests/unit/test_platform.cpp @@ -523,9 +523,9 @@ UTest a_carrier_without_hosts_serves_every_pair_and_none_serves_nothing{"Platfor expect(eq(mp::carrier_for(none, "linux-x86_64"), nullptr)); }}; -// Host pairs are this mcpp's; a descriptor key is any word, since a platform's -// rows may read blocks of their own (SPEC-016 §7). -UTest host_pairs_are_closed_and_descriptor_keys_are_words{"PlatformManifest/HostPairsAreClosedAndDescriptorKeysAreWords", [] { +// A host pair and a descriptor key are words: a platform's rows may name a +// system and read descriptor blocks of their own (SPEC-016 §7). +UTest host_pairs_and_descriptor_keys_are_words{"PlatformManifest/HostPairsAndDescriptorKeysAreWords", [] { const auto base = std::string(R"( [package] name = "p" @@ -534,7 +534,9 @@ version = "1.0.0" targets = ["x86_64-openkal"] )"); expect(ne(parse_error(base + R"(runner = { cmd = ["okrun"], hosts = ["linux"] })" "\n"), "")); - expect(ne(parse_error(base + R"(runner = { cmd = ["okrun"], hosts = ["freebsd-x86_64"] })" "\n"), "")); + // A system a platform defines is a host's system as well (mcpp#911 G10). + expect(eq(parse_error(base + R"(runner = { cmd = ["okrun"], hosts = ["freebsd-x86_64"] })" "\n"), "")); + expect(ne(parse_error(base + R"(runner = { cmd = ["okrun"], hosts = ["Linux-x86_64"] })" "\n"), "")); expect(ne(parse_error(base + R"(descriptor_keys = ["mac os"])" "\n").find("macosx"), std::string::npos)); expect(eq(parse_error(base + R"(descriptor_keys = ["freebsd", "linux"])" "\n"), "")); expect(ne(parse_error(base + R"(descriptor_keys = [])" "\n"), "")); From a3a24dbf6e5c19de3019740525490ea9e73b37a3 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 08:59:12 +0900 Subject: [PATCH 18/45] run: an artifact for another system is built, not run, until a runner states how (mcpp#911 G11) An artifact of a row a platform defines whose execution is another system is refused by `mcpp run` and reported not run by `mcpp test` unless a runner or an execution host is stated; a runner package is a target tool the runner names, started with the run protocol (docs/41). Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- docs/41-devices.md | 22 ++++++++++++ docs/zh/41-devices.md | 17 ++++++++++ src/build/execute.cppm | 34 +++++++++++++++---- tests/e2e/1040_a_platform_defines_a_target.sh | 12 +++++++ 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/docs/41-devices.md b/docs/41-devices.md index db3222791..ab52f6be0 100644 --- a/docs/41-devices.md +++ b/docs/41-devices.md @@ -142,6 +142,28 @@ A runner that executes the artifact where it lies ignores the variable. A runner that moves the artifact — to a device, a container, a remote host — copies each listed file to the destination beside the moved artifact. +### Runner packages and the systems a platform defines (2026.11.9.1+) + +A runner is a program; a package that builds it is a **runner package** (a virtual +machine, a remote host over ssh, a device). The project names the package as one +of the target's tools and the runner by the tool's name: + +```toml +[target.x86_64-freebsd] +runner = ["freebsd-vm", "{}"] + +[target.x86_64-freebsd.tools] +freebsd-vm = { git = "https://example.org/freebsd-vm.git", tag = "v1" } +``` + +The tool is built for this machine and started with the argv and the +environment above: it transfers the artifact and the files `MCPP_RUNTIME_FILES` +lists, runs the argv, streams the program's output, and exits with the +program's status. An artifact of a row a platform defines whose `execution` is +another system than this machine's, with no runner and no execution host +(`run_under`), is **built, not run**: `mcpp run` refuses it naming the keys that +state a runner, and `mcpp test` reports its tests as not run. + ## Running a distributable `mcpp run --pkg-format ` packs the distributable `` and runs it. Without diff --git a/docs/zh/41-devices.md b/docs/zh/41-devices.md index 76ab42c4e..d152a22b9 100644 --- a/docs/zh/41-devices.md +++ b/docs/zh/41-devices.md @@ -123,6 +123,23 @@ libfw.so/abs/project/target///bin/libfw.so 容器或远程主机——会把列出的每个文件复制到被移走产物旁边对应的目的 路径。 +### runner 包与平台定义的系统(2026.11.9.1+) + +runner 是一个程序;构建它的包是 **runner 包**(虚拟机、经 ssh 的远程主机、设备)。工程把该包写为目标的工具之一, +以工具名指定 runner: + +```toml +[target.x86_64-freebsd] +runner = ["freebsd-vm", "{}"] + +[target.x86_64-freebsd.tools] +freebsd-vm = { git = "https://example.org/freebsd-vm.git", tag = "v1" } +``` + +该工具为本机构建,以上述 argv 与环境启动:它传送产物及 `MCPP_RUNTIME_FILES` 所列文件,执行 argv,转发程序的输出, +并以程序的退出码退出。平台定义的行中,`execution` 为本机以外系统的产物,若无 runner 也无执行主机(`run_under`), +即**已构建、未运行**:`mcpp run` 拒绝并给出声明 runner 的键,`mcpp test` 把其测试报告为未运行。 + ## 运行一个可分发物 `mcpp run --pkg-format ` 打包可分发物 `` 并运行它。不带 `--runner` diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 2bffbabd8..1e7ba955b 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -1356,9 +1356,29 @@ ExecutionHost locate_run_under(const mcpp::build::BuildContext& ctx, std::string // and the OS run only there. `--no-runner` states that the triple is native // here, and is believed. std::optional unreachable_execution(const mcpp::build::BuildContext& ctx, - std::string_view tripleKey, bool noRunner) { + std::string_view tripleKey, bool noRunner, + bool runnerDeclared = false) { if (noRunner || !ctx.runUnder.empty()) return std::nullopt; auto t = mcpp::toolchain::triple::parse(tripleKey); + // AN ARTIFACT OF A SYSTEM A PLATFORM DEFINES runs where its row's + // `execution` names (mcpp#911 G11): on this machine when that is this + // machine's system, else through a runner -- a runner package's program, + // which reaches a VM, a remote host or a device by the run protocol + // (docs/41) -- or an execution host. With neither it is built, not run. + if (auto const* d = t ? t->defined() : nullptr; d && !runnerDeclared) { + const std::string_view system = d->execution.empty() ? std::string_view(t->os) : d->execution; + const std::string_view here = mcpplibs::os::is_windows ? "windows" + : mcpplibs::os::is_macos ? "macos" : "linux"; + if (system != here) + return std::format( + "{} artifacts run on {}, not on this {} host: built, not run.\n" + " State how they run: [target.{}] runner = [\"\", \"{{}}\"], the program a " + "runner package builds\n" + " ([target.{}.tools] = {{ ... }}; the run protocol is docs/41), or an " + "execution host:\n" + " [target.{}] run_under = [\"remote-run\", \"\", \"--\"].", + tripleKey, system, here, tripleKey, tripleKey, tripleKey); + } // A RUNNER THAT RUNS THE ARTIFACT ON A MACHINE OF ITS ARCHITECTURE // (`runner = { ..., host = "same-arch" }`, SPEC-013): an ok image's loader // is a program of this machine, and an image of another architecture is @@ -1534,7 +1554,7 @@ export RunnerOnThisMachine runner_on_this_machine(const BuildContext& ctx) { RunnerOnThisMachine out; const auto choice = choose_runner(ctx); out.arch = std::string(mcpp::toolchain::platform::host_arch); - if (auto why = unreachable_execution(ctx, choice.tripleKey, false)) out.refusal = *why; + if (auto why = unreachable_execution(ctx, choice.tripleKey, false, !choice.tmpl.empty())) out.refusal = *why; if (!ctx.runUnder.empty()) { auto host = locate_run_under(ctx, choice.tripleKey); if (!host.error.empty()) out.refusal = host.error; @@ -3317,7 +3337,7 @@ int run_artifact_via_runner(mcpp::build::BuildContext& ctx, return 126; } if (isRunSlot) { - if (auto why = unreachable_execution(ctx, choice.tripleKey, no_runner)) { + if (auto why = unreachable_execution(ctx, choice.tripleKey, no_runner, !choice.tmpl.empty())) { std::println(libc::stderr(), "error: {}", *why); return 2; } @@ -3504,8 +3524,9 @@ std::optional choose_run_leg(std::spantriple, ctx.error())); return std::nullopt; } - const auto key = choose_runner(*ctx, noRunner).tripleKey; - if (auto why = unreachable_execution(*ctx, key, noRunner)) { + const auto chosen = choose_runner(*ctx, noRunner); + const auto key = chosen.tripleKey; + if (auto why = unreachable_execution(*ctx, key, noRunner, !chosen.tmpl.empty())) { passed.push_back(std::format("{}: {}", leg->triple, why->substr(0, why->find('\n')))); continue; } @@ -4463,7 +4484,8 @@ static void test_resolve_runner(TestBuild& tb, const TestOptions& testOpts, bool } // Not run, with the reason, as a missing runner is: the tests built. Or, // where the caller asks, built and not run (a platform package's targets). - if (auto why = unreachable_execution(*ctx, runnerChoice.tripleKey, testOpts.noRunner)) { + if (auto why = unreachable_execution(*ctx, runnerChoice.tripleKey, testOpts.noRunner, + !runnerChoice.tmpl.empty())) { if (testOpts.unreachableIsBuilt) { tb.builtNotRunHere = true; if (!json) mcpp::ui::info("note", std::format("built, not run on this host: {}", *why)); diff --git a/tests/e2e/1040_a_platform_defines_a_target.sh b/tests/e2e/1040_a_platform_defines_a_target.sh index 117b1c982..b9e62ec0f 100644 --- a/tests/e2e/1040_a_platform_defines_a_target.sh +++ b/tests/e2e/1040_a_platform_defines_a_target.sh @@ -96,6 +96,18 @@ out=$("$MCPP" build --target x86_64-toyos 2>&1) || fail "a row for another syste grep -q "the artifacts are for toyos, not this machine" <<<"$out" || fail "the build judged a foreign artifact by this machine" "$out" echo " ok an artifact for another system is built and its closure is not judged here" +# An artifact for another system is built, not run, without a runner; a runner states how it runs +# (mcpp#911 G11). +set +e; out=$("$MCPP" run --target x86_64-toyos 2>&1); rc=$?; set -e +[ "$rc" != 0 ] || fail "an artifact for toyos was run on this host" "$out" +grep -q "x86_64-toyos artifacts run on toyos, not on this .* host: built, not run" <<<"$out" \ + || fail "the refusal does not say built, not run" "$out" +sed -i 's|^platform = { path = "../toy" }$|&\nrunner = ["env", "{}"]|' mcpp.toml +set +e; out=$("$MCPP" run --target x86_64-toyos 2>&1); rc=$?; set -e +[ "$rc" = 7 ] || fail "the runner did not run it (exit $rc)" "$out" +sed -i '/^runner = \["env", "{}"\]$/d' mcpp.toml +echo " ok without a runner it is built, not run; a runner runs it" + # The platform names its programs; a build program that names them for the host does not change # the platform's suffix, and the build says so (mcpp#911 B2). sed -i 's/^driver = "compiler"$/driver = "compiler"\nexe = "{name}.toy"/' "$t/toy/mcpp.toml" From af4f2040b1efab630fe88f192753d7f82e36d05e Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 09:09:47 +0900 Subject: [PATCH 19/45] =?UTF-8?q?references:=20a=20git=20reference=20follo?= =?UTF-8?q?ws=20the=20default=20branch;=20git+=20on=20the=20command=20line?= =?UTF-8?q?=20(SPEC-001=20=C2=A75,=20SPEC-016=20=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A git dependency names at most one of rev, tag, branch; none follows the remote's default branch, announced by `git ls-remote --symref` (main, then master, otherwise), locked by commit and read offline from the lock. - The string form of a reference takes `git+[#tag=|rev=|branch=]`: `--platform git+#tag=` selects a platform from its repository; a repository named by URL alone is the package its last segment names. - A row a platform defines is known whether or not the project writes a section for it. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- modules/manifest/src/platform.cppm | 23 ++++ modules/manifest/src/toml.cppm | 11 +- src/build/prepare/graph.cpp | 36 +++++++ src/build/prepare/platform.cpp | 10 ++ src/build/prepare/toolchain.cpp | 4 +- src/platform/platform.cppm | 2 +- ...it_reference_follows_the_default_branch.sh | 102 ++++++++++++++++++ tests/unit/test_manifest.cpp | 27 +++++ tests/unit/test_platform.cpp | 16 +++ 9 files changed, 225 insertions(+), 6 deletions(-) create mode 100755 tests/e2e/1048_a_git_reference_follows_the_default_branch.sh diff --git a/modules/manifest/src/platform.cppm b/modules/manifest/src/platform.cppm index 82c50535a..41fb000de 100644 --- a/modules/manifest/src/platform.cppm +++ b/modules/manifest/src/platform.cppm @@ -404,8 +404,31 @@ inline const Carrier* carrier_for(const Statement& s, std::string_view pair) { // `"[.]@"` -> the ref; the error names what is // missing. `none` is not a ref; callers test for it first. +// THE STRING FORM OF A REFERENCE (SPEC-016 §8, SPEC-001 §5): `[.]@`, +// or `git+[#tag=|rev=|branch=]` -- no fragment is the remote's +// default branch. A path is read by the caller, which knows the directory. inline std::expected parse_ref_text(std::string_view text) { PackageRef r; + if (text.starts_with("git+")) { + auto rest = text.substr(4); + const auto hash = rest.find('#'); + r.spec.git = std::string(rest.substr(0, hash)); + if (r.spec.git.empty()) + return std::unexpected(std::format("'{}' names no repository after `git+`", text)); + r.spec.gitRefKind = "branch"; // the default branch, resolved when it is fetched + if (hash != std::string_view::npos) { + const auto frag = rest.substr(hash + 1); + const auto eq = frag.find('='); + const auto kind = frag.substr(0, eq); + if (eq == std::string_view::npos || eq + 1 == frag.size() + || (kind != "tag" && kind != "rev" && kind != "branch")) + return std::unexpected(std::format( + "'{}': after `#` write tag=, rev= or branch=", text)); + r.spec.gitRefKind = std::string(kind); + r.spec.gitRev = std::string(frag.substr(eq + 1)); + } + return r; + } const auto at = text.rfind('@'); if (at == std::string_view::npos || at == 0 || at + 1 == text.size()) return std::unexpected(std::format( diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 29d472673..23db21bd2 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -2604,6 +2604,12 @@ std::expected parse_document(const t::Document& documen if (auto it = sub.find("backend"); it != sub.end() && it->second.is_string()) { spec.features.push_back("backend-" + it->second.as_string()); } + // AT MOST ONE OF `rev`, `tag`, `branch` (SPEC-001 §5); none is the + // remote's default branch, resolved when the dependency is fetched. + if (int refs = int(sub.contains("rev")) + int(sub.contains("tag")) + int(sub.contains("branch")); refs > 1) + return std::unexpected(error(origin, std::format( + "[{}.\"{}\"] names {} of rev / tag / branch; a git dependency names at most one", + section, fqName, refs))); if (auto it = sub.find("rev"); it != sub.end() && it->second.is_string()) { spec.gitRev = it->second.as_string(); spec.gitRefKind = "rev"; @@ -2636,10 +2642,7 @@ std::expected parse_document(const t::Document& documen return std::unexpected(error(origin, std::format( "[{}.\"{}\"] must specify 'path', 'version', or 'git'", section, fqName))); } - if (!spec.git.empty() && spec.gitRev.empty()) { - return std::unexpected(error(origin, std::format( - "[{}.\"{}\"] git dep requires one of: rev / tag / branch", section, fqName))); - } + if (!spec.git.empty() && spec.gitRefKind.empty()) spec.gitRefKind = "branch"; // the default branch return {}; }; diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index 586c6815b..d41164c53 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -1472,6 +1472,42 @@ step4b_acquire_dependency_source(PrepareState& state, WorklistItemCtx& ctx) { const std::string lockKey = rootDeclared ? name : mcpp::pm::compat::qualified_name( spec.namespace_, spec.shortName.empty() ? name : spec.shortName); + // THE DEFAULT BRANCH (SPEC-001 §5): a git reference that names no + // `tag`, `rev` or `branch` follows the remote's default branch, + // whose name the remote announces (`git ls-remote --symref`); the + // lock's entry for the same remote answers it offline. A remote + // that announces none is tried for `main`, then `master`. + if (spec.gitRefKind == "branch" && spec.gitRev.empty()) { + if (auto it = state.gitLockAnchors.find(lockKey); + it != state.gitLockAnchors.end() && it->second.url == spec.git + && it->second.refKind == "branch" && !it->second.ref.empty()) + spec.gitRev = it->second.ref; + else if (offline) + return refuse_offline("`git ls-remote`", + "its default branch is resolved from the remote, and mcpp.lock records none", + "resolve"); + else { + auto head = run_with_network_retry({ "git", "ls-remote", "--symref", spec.git, "HEAD" }); + if (head.exit_code == 0) + if (auto p = head.output.find("ref: refs/heads/"); p != std::string::npos) { + const auto from = p + 16; + const auto end = head.output.find_first_of("\t \r\n", from); + spec.gitRev = head.output.substr(from, end == std::string::npos ? end : end - from); + } + for (auto candidate : { "main", "master" }) { + if (!spec.gitRev.empty()) break; + auto r = run_with_network_retry({ "git", "ls-remote", spec.git, + std::format("refs/heads/{}", candidate) }); + if (r.exit_code == 0 && !r.output.empty()) spec.gitRev = candidate; + } + if (spec.gitRev.empty()) + return std::unexpected(std::format( + "git dependency '{}': '{}' announces no default branch and has neither " + "`main` nor `master`; name one with tag, rev or branch", name, spec.git)); + mcpp::ui::info("Resolved", std::format("{}: the default branch of {} is {}", + name, spec.git, spec.gitRev)); + } + } if (spec.gitRefKind == "branch") { auto it = state.gitLockAnchors.find(lockKey); if (it != state.gitLockAnchors.end() diff --git a/src/build/prepare/platform.cpp b/src/build/prepare/platform.cpp index 9bc207fec..e0a255f41 100644 --- a/src/build/prepare/platform.cpp +++ b/src/build/prepare/platform.cpp @@ -66,6 +66,16 @@ std::string known_short_name(const mp::PackageRef& r) { .shortName; return {}; } + // A repository named by its URL alone is the package its last segment + // names (`.../openkal-platform.git` is `openkal-platform`); the fetched + // manifest's own name is checked against it as any dependency's is. + if (!r.spec.git.empty()) { + std::string_view url = r.spec.git; + while (!url.empty() && (url.back() == '/' || url.back() == '\\')) url.remove_suffix(1); + if (url.ends_with(".git")) url.remove_suffix(4); + const auto cut = url.find_last_of("/\\:"); + return std::string(cut == std::string_view::npos ? url : url.substr(cut + 1)); + } return {}; } diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index e563a53ce..9d54e704d 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -706,7 +706,9 @@ step1_validate_target_tier(PrepareState& state, TargetOverrideCtx& ctx) { " Name one of them.", ctx.requestedSpelling, opts)); } - if (!ctx.known && !ctx.hasExplicitSection) { + // A row a platform defines is known to this build by its definition + // (SPEC-016 §2), whether or not the project writes a section for it. + if (!ctx.known && !ctx.hasExplicitSection && !(ctx.parsed && ctx.parsed->defined())) { // "UNKNOWN" IS A CLAIM ABOUT THE VOCABULARY, AND IT WAS FALSE FOR // A WHOLE arch+os FAMILY. // diff --git a/src/platform/platform.cppm b/src/platform/platform.cppm index 32874f270..46ba8cb98 100644 --- a/src/platform/platform.cppm +++ b/src/platform/platform.cppm @@ -200,7 +200,7 @@ namespace { namespace mp = mcpp::manifest::platform; bool names_a_path(std::string_view v) { - if (v.empty()) return false; + if (v.empty() || v.starts_with("git+")) return false; // a repository (SPEC-016 §8) if (v.starts_with("path:")) return true; if (v.starts_with("/") || v.starts_with("./") || v.starts_with("../") || v.starts_with("~") || v == "." || v == "..") diff --git a/tests/e2e/1048_a_git_reference_follows_the_default_branch.sh b/tests/e2e/1048_a_git_reference_follows_the_default_branch.sh new file mode 100755 index 000000000..30319b0a5 --- /dev/null +++ b/tests/e2e/1048_a_git_reference_follows_the_default_branch.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# requires: elf llvm +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1048 -- a git reference with no tag, rev or branch follows the remote's default branch; a +# reference on the command line takes the same forms (SPEC-001 §5; SPEC-016 §8). +# +# The default branch is the one the remote announces (`git ls-remote --symref`), here `trunk`; the +# lock records it and its commit, and an offline build reads them. A dependency that names two of +# rev / tag / branch is refused. `--platform git+#tag=` selects a platform from a +# repository as a manifest's `{ git = ..., tag = ... }` does. +# +# Criteria: the build reports the default branch and runs; mcpp.lock names `#branch=trunk@`; +# an offline rebuild succeeds; two refs are refused; the platform from git builds its row. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +g() { git -c user.name=e2e -c user.email=e2e@example.invalid -c init.defaultBranch=trunk "$@"; } +mkdir -p "$t/lib/src" "$t/app/src" "$t/toy-platform" "$t/bare/src" +cat > "$t/lib/mcpp.toml" <<'EOF' +[package] +name = "gitlib" +version = "0.1.0" + +[targets.gitlib] +kind = "lib" +EOF +echo 'int gitlib_value() { return 9; }' > "$t/lib/src/lib.cpp" +(cd "$t/lib" && g init -q . && g add -A && g commit -qm init) +cat > "$t/app/mcpp.toml" < "$t/app/src/main.cpp" +cd "$t/app" + +out=$("$MCPP" build 2>&1) || fail "the build failed" "$out" +grep -q "the default branch of .* is trunk" <<<"$out" || fail "the default branch is not reported" "$out" +set +e; "$(find target -path '*/bin/app' -type f | head -1)"; rc=$?; set -e +[ "$rc" = 9 ] || fail "the program returned $rc, not 9" +grep -Eq 'source = "git\+.*#branch=trunk@[0-9a-f]{40}"' mcpp.lock || fail "the lock does not record the branch and its commit" "$(cat mcpp.lock)" +rm -rf target +out=$("$MCPP" build --offline 2>&1) || fail "the offline build did not read the lock" "$out" +echo " ok the default branch is followed, locked, and read offline" + +sed -i "s|gitlib = { git = \"$t/lib\" }|gitlib = { git = \"$t/lib\", tag = \"v1\", branch = \"trunk\" }|" mcpp.toml +out=$("$MCPP" build 2>&1 || true) +grep -q "names 2 of rev / tag / branch; a git dependency names at most one" <<<"$out" || fail "two refs were not refused" "$out" +echo " ok a dependency that names two refs is refused" + +cat > "$t/toy-platform/mcpp.toml" < "$t/bare/mcpp.toml" <<'EOF' +[package] +name = "bare" +version = "0.1.0" + +[build] +sources = ["src/*.c"] +ldflags = ["-nostdlib", "-static", "-no-pie"] + +[targets.bare] +kind = "bin" +main = "src/main.c" +EOF +printf 'void _start(void) { __asm__ volatile("mov $60, %%eax\\n mov $7, %%edi\\n syscall"); }\n' > "$t/bare/src/main.c" +cd "$t/bare" +out=$("$MCPP" build --target x86_64-toyos --platform "git+$t/toy-platform#tag=v1" 2>&1) || fail "the platform from git did not build its row" "$out" +grep -q "Platform toy-platform 0.1.0" <<<"$out" || fail "the platform is not reported" "$out" +echo " ok --platform git+#tag= selects the platform from its repository" +echo "PASS: 1048 a git reference follows the default branch" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 745944648..62a50599e 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -7090,3 +7090,30 @@ target = "x86_64-openkal" )"); if (auto&& failed = expect(fatal(ok.has_value())); !failed) failed << ok.error().format(); }}; + +// A GIT DEPENDENCY NAMES AT MOST ONE OF rev, tag, branch (SPEC-001 §5); none is +// the remote's default branch. +UTest a_git_dependency_names_at_most_one_ref{"Manifest/AGitDependencyNamesAtMostOneRef", [] { + auto m = mcpp::manifest::parse_string(R"( +[package] +name = "x" +version = "0.1.0" + +[dependencies] +bar = { git = "https://example.com/bar.git" } +)"); + if (auto&& failed = expect(fatal(m.has_value())); !failed) failed << m.error().format(); + auto const& bar = m->dependencies.at("bar"); + expect(eq(bar.gitRefKind, "branch")); + expect(bar.gitRev.empty()); + auto two = mcpp::manifest::parse_string(R"( +[package] +name = "x" +version = "0.1.0" + +[dependencies] +bar = { git = "https://example.com/bar.git", tag = "v1", rev = "abc" } +)"); + expect(fatal(!two.has_value())); + expect(ne(two.error().format().find("a git dependency names at most one"), std::string::npos)); +}}; diff --git a/tests/unit/test_platform.cpp b/tests/unit/test_platform.cpp index 5c23fee6d..65a49a507 100644 --- a/tests/unit/test_platform.cpp +++ b/tests/unit/test_platform.cpp @@ -613,3 +613,19 @@ targets = ["x86_64-none-elf"] expect(eq(march->origin.tier, mp::Tier::Manifest)); expect(eq(merged.replace.size(), 2u)); }}; + +// THE STRING FORM OF A REPOSITORY (SPEC-016 §8): `git+` with at most one of +// tag, rev, branch after `#`; none is the remote's default branch. +UTest a_repository_reference_has_a_string_form{"PlatformManifest/ARepositoryReferenceHasAStringForm", [] { + auto tagged = mp::parse_ref_text("git+https://example.org/acme/toy-platform.git#tag=v1"); + expect(fatal(tagged.has_value())); + expect(eq(tagged->spec.git, "https://example.org/acme/toy-platform.git")); + expect(eq(tagged->spec.gitRefKind, "tag")); + expect(eq(tagged->spec.gitRev, "v1")); + auto deflt = mp::parse_ref_text("git+git@example.org:acme/toy.git"); + expect(fatal(deflt.has_value())); + expect(eq(deflt->spec.gitRefKind, "branch")); + expect(deflt->spec.gitRev.empty()); + expect(!mp::parse_ref_text("git+https://example.org/x.git#sha=1").has_value()); + expect(!mp::parse_ref_text("git+").has_value()); +}}; From cc067419770b1a60c36403fd8438ef649e950182 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 09:13:10 +0900 Subject: [PATCH 20/45] =?UTF-8?q?mcpp=20new=20--template=20platform;=20mcp?= =?UTF-8?q?p=20check=20platform=20(SPEC-016=20=C2=A72,=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The platform skeleton defines - and the provider that serves it, with the llvm the built-in bare-metal rows pin. - `mcpp check platform [dir]` reads a platform's statement as the engine does: rows (segments, built-in redefinitions, repeats), providers (rows served, shared priorities), and the identities served with provider and priority. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- src/cli.cppm | 16 ++- src/cli/cmd_check.cpp | 121 ++++++++++++++++++ src/cli/cmd_check.cppm | 18 +++ src/cli/cmd_new.cppm | 1 + src/scaffold/create.cppm | 84 ++++++++++++ ...a_platform_skeleton_is_made_and_checked.sh | 58 +++++++++ 6 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 src/cli/cmd_check.cpp create mode 100644 src/cli/cmd_check.cppm create mode 100755 tests/e2e/1049_a_platform_skeleton_is_made_and_checked.sh diff --git a/src/cli.cppm b/src/cli.cppm index 61e413c6c..6adcbb7d2 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -20,6 +20,7 @@ import mcpplibs.cmdline; import mcpp.cli.cmd_build; import mcpp.cli.cmd_cache; import mcpp.cli.cmd_patch; +import mcpp.cli.cmd_check; import mcpp.cli.cmd_new; import mcpp.cli.cmd_publish; import mcpp.cli.cmd_xpkg; @@ -77,6 +78,7 @@ void print_usage() { std::println(" mcpp add [ns.]pkg@ver Add an exact dependency to mcpp.toml"); std::println(" mcpp remove [ns.]pkg Remove an exact dependency from mcpp.toml"); std::println(" mcpp patch edit|save Edit a dependency's sources; save the change as its patch"); + std::println(" mcpp check platform [dir] Check a platform package's rows, providers and served targets"); std::println(" mcpp update [pkg] Re-resolve deps and rewrite mcpp.lock"); std::println(" mcpp search Search packages in registries"); std::println(" mcpp publish [--dry-run] Publish package to default registry"); @@ -682,7 +684,7 @@ int run(int argc, char** argv) { // (cmd_new validates presence for project creation itself). .arg(cl::Arg("name").help("Package directory name")) .option(cl::Option("template").short_name('t').takes_value().value_name("SPEC") - .help("bin (default) | lib | [ns.]pkg[@ver][:template] — exact package template")) + .help("bin (default) | lib | platform | [ns.]pkg[@ver][:template] — exact package template")) .option(cl::Option("list-templates").takes_value().value_name("PKG") .help("List templates from exact [ns.]pkg[@ver]")) .action(wrap_rc(cmd_new))) @@ -1223,6 +1225,16 @@ int run(int argc, char** argv) { .option(cl::Option("target").takes_value().help( "Remove the payload for instead of the host one"))) .action(wrap_rc(cmd_toolchain_any))) + .subcommand(cl::App("check") + .description("Check a package's statement as the engine reads it") + .subcommand(cl::App("platform") + .description("Check a platform package: its rows, its providers and the identities it serves") + .arg(cl::Arg("dir").help("The platform package's directory (default: this one)"))) + .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { + return dispatch_sub("check", p, { + {"platform", cmd_check_platform}, + }); + }))) .subcommand(cl::App("patch") .description("Edit a dependency's sources and save the change as its patch") .subcommand(cl::App("edit") @@ -1940,7 +1952,7 @@ int run(int argc, char** argv) { static constexpr std::array known = std::to_array({ "new", "build", "run", "test", "clean", "add", "remove", "update", "search", "publish", "pack", "emit", "xpkg", - "toolchain", "cache", "patch", "index", "self", "explain", + "toolchain", "cache", "patch", "check", "index", "self", "explain", "version", "dyndep", "why", "resolve", "stage", "bmi-equal", "coff-def", "place-dlls", "link-output", "platform-step", "bmi-compile", "bmi-supervise", "bmi-await", diff --git a/src/cli/cmd_check.cpp b/src/cli/cmd_check.cpp new file mode 100644 index 000000000..c783d59dc --- /dev/null +++ b/src/cli/cmd_check.cpp @@ -0,0 +1,121 @@ +// mcpp.cli.cmd_check — the implementation, an implementation unit for the +// reason mcpp.cli.cmd_patch states. +module mcpp.cli.cmd_check; + +import std; +import mcpplibs.cmdline; +import mcpp.manifest; +import mcpp.toolchain.target_schema; +import mcpp.toolchain.target_rows; +import mcpp.ui; + +namespace mcpp::cli { + +namespace { + +namespace fs = std::filesystem; +namespace mp = mcpp::manifest::platform; +namespace target = mcpp::toolchain::target; + +std::vector segments(std::string_view triple) { + std::vector out; + for (auto part : std::views::split(triple, '-')) out.emplace_back(part.begin(), part.end()); + return out; +} + +bool word(std::string_view w) { + return !w.empty() && std::ranges::all_of(w, [](char c) { + return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.'; + }); +} + +} // namespace + +int cmd_check_platform(const mcpplibs::cmdline::ParsedArgs& parsed) { + const fs::path dir = parsed.positional(0).empty() ? fs::current_path() : fs::path(parsed.positional(0)); + auto m = mcpp::manifest::load(dir / "mcpp.toml"); + if (!m) { + mcpp::ui::error(m.error().format()); + return 1; + } + if (!m->platformDeclared) { + mcpp::ui::error(std::format("{} states no [platform]: it is not a platform package", + (dir / "mcpp.toml").string())); + return 1; + } + for (auto const& w : m->schemaWarnings) mcpp::ui::warning(w); + auto const& s = m->platform; + std::vector errors, warnings; + std::set archs, own; + for (auto const* r : target::rows()) archs.insert(std::string(r->arch)); + + for (auto const& d : s.defines) { + const auto seg = segments(d.triple); + if (seg.size() < 2 || seg.size() > 3 || !std::ranges::all_of(seg, word)) { + errors.push_back(std::format("[[platform.define]] '{}' is not -[-] of words", d.triple)); + continue; + } + if (!archs.contains(seg[0])) + warnings.push_back(std::format("'{}': no built-in row has the architecture '{}'", d.triple, seg[0])); + if (target::find_row(d.triple) && !own.contains(d.triple)) + errors.push_back(std::format("'{}' is a built-in row; a platform serves it with " + "[[platform.provider]] serves = [\"{}\"]", d.triple, d.triple)); + if (!own.insert(d.triple).second) + errors.push_back(std::format("'{}' is defined twice", d.triple)); + if (d.clangTriple.empty()) + warnings.push_back(std::format("'{}' states no clang-triple: the compiler is given the row's own spelling", + d.triple)); + } + + // Each identity this platform serves, with the provider and priority. + struct Served { std::string identity, provider; int priority; }; + std::vector served; + auto rows_matching = [&](std::string_view pattern) { + std::vector out; + for (auto const& t : own) if (target::serves_triple(pattern, t)) out.push_back(t); + for (auto const* r : target::rows()) + if (target::serves_triple(pattern, r->triple) && !own.contains(std::string(r->triple))) + out.emplace_back(r->triple); + return out; + }; + const std::string platformName = m->package.name; + if (s.targets.set) + for (auto const& t : s.targets.value) + for (auto const& id : rows_matching(t)) + served.push_back({ id, platformName, s.prioritySet ? s.priority : 100 }); + for (auto const& p : s.providers) { + const auto name = p.name.empty() ? platformName : p.name; + std::size_t n = 0; + for (auto const& pattern : p.serves) + for (auto const& id : rows_matching(pattern)) { + served.push_back({ id, name, p.priority }); + ++n; + } + if (n == 0) + warnings.push_back(std::format("the provider '{}' serves no row ({})", name, + p.serves.empty() ? std::string("serves is empty") : p.serves.front())); + } + std::ranges::sort(served, [](auto const& a, auto const& b) { + return std::tie(a.identity, b.priority, a.provider) < std::tie(b.identity, a.priority, b.provider); + }); + for (std::size_t i = 1; i < served.size(); ++i) + if (served[i].identity == served[i - 1].identity && served[i].priority == served[i - 1].priority + && served[i].provider != served[i - 1].provider) + warnings.push_back(std::format("'{}' is served by '{}' and '{}' at priority {}: the name orders them", + served[i].identity, served[i - 1].provider, served[i].provider, + served[i].priority)); + if (served.empty()) errors.push_back("the platform serves no identity: state targets or [[platform.provider]] serves"); + + mcpp::ui::info("Platform", std::format("{} {}", m->package.name, m->package.version)); + for (auto const& e : served) + mcpp::ui::line(std::format(" serves {:<28} {:<20} priority {}", e.identity, e.provider, e.priority)); + for (auto const& w : warnings) mcpp::ui::warning(w); + for (auto const& e : errors) mcpp::ui::error(e); + if (!errors.empty()) return 1; + mcpp::ui::info("Checked", std::format("{} row(s) defined, {} identit{} served{}", own.size(), served.size(), + served.size() == 1 ? "y" : "ies", + warnings.empty() ? "" : std::format(", {} warning(s)", warnings.size()))); + return 0; +} + +} // namespace mcpp::cli diff --git a/src/cli/cmd_check.cppm b/src/cli/cmd_check.cppm new file mode 100644 index 000000000..e4e4fc3e1 --- /dev/null +++ b/src/cli/cmd_check.cppm @@ -0,0 +1,18 @@ +// mcpp.cli.cmd_check — `mcpp check platform []` (SPEC-016 §2, §3). +// +// A platform package's statement checked as the engine reads it: the rows it +// defines (their segments, that they name no built-in row, that none repeats), +// the providers (that each serves a row, and which share a row at one +// priority), and the identities it serves, each with its provider and +// priority. Errors end the command with 1; warnings do not. + +export module mcpp.cli.cmd_check; + +import std; +import mcpplibs.cmdline; + +export namespace mcpp::cli { + +int cmd_check_platform(const mcpplibs::cmdline::ParsedArgs& parsed); + +} // namespace mcpp::cli diff --git a/src/cli/cmd_new.cppm b/src/cli/cmd_new.cppm index 0dab4e535..ff1564219 100644 --- a/src/cli/cmd_new.cppm +++ b/src/cli/cmd_new.cppm @@ -62,6 +62,7 @@ export int cmd_new(const mcpplibs::cmdline::ParsedArgs& parsed) { "--template gui is deprecated; use `--template imgui` " "(the template then ships with — and version-tracks — the library)"); } + if (tmpl == "platform") return mcpp::scaffold::create_platform_project(*project); if (tmpl != "bin" && tmpl != "gui" && tmpl != "lib") { auto spec = mcpp::scaffold::parse_template_spec(tmpl); if (!spec) { diff --git a/src/scaffold/create.cppm b/src/scaffold/create.cppm index f333bf8df..6868ef878 100644 --- a/src/scaffold/create.cppm +++ b/src/scaffold/create.cppm @@ -17,6 +17,8 @@ import mcpp.scaffold; import mcpp.scaffold.project_name; import mcpp.ui; import mcpp.build.test_protocol; // the release of mtest the skeleton's tests use +import mcpp.toolchain.target_schema; +import mcpp.toolchain.target_rows; // the llvm a platform skeleton pins, as the built-in rows do namespace mcpp::scaffold { @@ -300,6 +302,11 @@ export int new_from_package_template( // gui alias) and a library (lib). export enum class builtin { bin, gui, lib }; +// `mcpp new --template platform` (SPEC-016 §2, §3): a platform package that +// defines one row, `-`, and the provider that serves it, with the +// llvm the built-in bare-metal rows pin. `mcpp check platform` reads it. +export int create_platform_project(const mcpp::scaffold::PortableProjectName& project); + // What a project's library is named in C++: its module, the qualified name // with `-` written `_` (`acme.my-lib` -> `acme.my_lib`), and its namespace // (`acme::my_lib`). Empty when a component does not begin with a letter or @@ -564,4 +571,81 @@ UTest greeting{"hello greets by name", [] { return 0; } +int create_platform_project(const mcpp::scaffold::PortableProjectName& project) { + // The system's word: the package's name, letters and digits. + std::string os; + for (char c : project.name) + if (std::isalnum(static_cast(c))) os += static_cast(std::tolower(static_cast(c))); + if (os.empty() || !std::isalpha(static_cast(os.front()))) os = "example"; + std::string llvm; + for (auto const* p : mcpp::toolchain::target::providers()) + if (p->toolchain.starts_with("llvm@")) { llvm = std::string(p->toolchain); break; } + const std::string arch(mcpp::toolchain::platform::host_arch); + const std::string triple = std::format("{}-{}", arch, os); + + auto transaction = mcpp::scaffold::ScaffoldTransaction::begin( + std::filesystem::current_path(), project.directoryName); + if (!transaction) { + mcpp::ui::error(transaction.error()); + return 1; + } + auto tx = std::move(*transaction); + const auto& root = tx.staging_path(); + const std::string manifest = std::format(R"(# A platform (SPEC-016): the rows it defines and the provider that serves them. +# A project selects it for a target with [target.] platform = {{ path = "..." }}. +[package] +name = "{0}" +version = "0.1.0" +description = "The platform of {1}" + +[platform] +toolchain = "{2}" + +# The row: the system's triple, what the compiler is told, the object format, +# and the system its programs run on. +[[platform.define]] +triple = "{1}" +clang-triple = "{3}-unknown-none-elf" +object-format = "elf" +execution = "{4}" + +[[platform.provider]] +serves = ["{1}"] + +[platform.build] +cflags = ["-ffreestanding"] + +[platform.link] +driver = "compiler" + +# What the platform provides for the target side; `false`: nothing yet. +[platform.layers] +"kernel-abi" = false +"c-abi" = false +"c++-abi" = false +"compiler-runtime" = false +)", project.qualifiedName, triple, llvm, arch, os); + if (auto written = mcpp::scaffold::write_text_file(root / "mcpp.toml", manifest); !written) { + mcpp::ui::error(written.error()); + return 1; + } + if (auto written = mcpp::scaffold::write_text_file(root / ".gitignore", "target/\n.mcpp/\n"); !written) { + mcpp::ui::error(written.error()); + return 1; + } + auto generated = mcpp::manifest::load(root / "mcpp.toml"); + if (!generated) { + mcpp::ui::error(std::format("generated manifest validation failed: {}", generated.error().format())); + return 1; + } + if (auto committed = tx.commit(); !committed) { + mcpp::ui::error(committed.error()); + return 1; + } + mcpp::ui::line(std::format("Created platform package '{}' at {}, defining {}", project.qualifiedName, + tx.final_path().string(), triple)); + mcpp::ui::line(std::format("Next: cd {} && mcpp check platform", project.directoryName)); + return 0; +} + } // namespace mcpp::scaffold diff --git a/tests/e2e/1049_a_platform_skeleton_is_made_and_checked.sh b/tests/e2e/1049_a_platform_skeleton_is_made_and_checked.sh new file mode 100755 index 000000000..ed6cff1d6 --- /dev/null +++ b/tests/e2e/1049_a_platform_skeleton_is_made_and_checked.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# requires: elf llvm +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1049 -- a platform skeleton is made and checked (SPEC-016 §2, §3). +# +# `mcpp new --template platform` writes a platform package that defines `-` and +# the provider that serves it; `mcpp check platform` reads it as the engine does and lists the +# identities it serves. A row that names a built-in row is refused, and a provider that serves +# nothing is said. +# +# Criteria: the skeleton checks clean and serves x86_64- (on x86_64); a program builds for +# the row; a redefined built-in row ends the check with 1; a provider serving nothing is a warning. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +cd "$t" +arch=$(uname -m); [ "$arch" = arm64 ] && arch=aarch64 +out=$("$MCPP" new myos --template platform 2>&1) || fail "the skeleton was not made" "$out" +out=$(cd myos && "$MCPP" check platform 2>&1) || fail "the skeleton does not check" "$out" +grep -Eq "serves $arch-myos +myos +priority 100" <<<"$out" || fail "the check does not list the served row" "$out" +echo " ok the skeleton checks and serves $arch-myos" + +mkdir -p app/src +cat > app/mcpp.toml < app/src/main.c +out=$(cd app && "$MCPP" build --target "$arch-myos" 2>&1) || fail "a program did not build for the row" "$out" +echo " ok a program builds for the platform's row" + +cat >> myos/mcpp.toml <<'EOF' + +[[platform.define]] +triple = "x86_64-linux-gnu" + +[[platform.provider]] +name = "nothing" +serves = ["riscv64-nosuch"] +EOF +set +e; out=$(cd myos && "$MCPP" check platform 2>&1); rc=$?; set -e +[ "$rc" = 1 ] || fail "a redefined built-in row did not fail the check (exit $rc)" "$out" +grep -q "'x86_64-linux-gnu' is a built-in row" <<<"$out" || fail "the redefinition is not named" "$out" +grep -q "the provider 'nothing' serves no row" <<<"$out" || fail "a provider serving nothing is not said" "$out" +echo " ok a redefined built-in row fails the check; a provider serving nothing is said" +echo "PASS: 1049 a platform skeleton is made and checked" From bdafae6dd27002d7578619301c7578e26cd28070 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 09:13:52 +0900 Subject: [PATCH 21/45] 2026.11.9.1: version and CHANGELOG Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 79 +++++++++++++++++++++++++++++ mcpp.toml | 2 +- modules/versioning/src/version.cppm | 2 +- 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 832353891..0ef2b923d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,85 @@ > Each `## []` section is that release's notes. Entries are written in English > from 2026.9.28.3 on; earlier entries remain as written. +## [2026.11.9.1] - 2026-11-09 + +### Added + +- **Targets and providers (SPEC-016).** A target request is a pattern: the + rows it reaches, each with the providers that serve it, are ordered by + priority, then by row, then by provider name, and the first one available on + this host is taken; the build reports the request, the identity and, when the + first candidate is not available, why. The built-in rows and providers are + data (`modules/toolchain-model/data/builtin.toml`, compiled in by the + package's build program). `[target..priority] = ` + changes the order; `platform = "builtin:"` chooses a built-in provider. +- A platform package defines rows and providers: `[[platform.define]]` (triple, + clang-triple, object format, family, execution, ...) and + `[[platform.provider]]` (serves, priority, hosts, pairs, toolchain, layers, + link, hosted). `cfg(os = ...)` answers by a defined row; the parser accepts + the systems and C libraries the resolved platforms define (mcpp#911 G1). +- `mcpp why target` lists a request's candidates with their priorities and + availability; `mcpp why flags []` lists each word of a unit's command + line and of the link line with the layer that gave it (row, replacement, + platform, toolchain, package, engine). Both take `--format json`. The build + record and the `plan` event carry `{request, identity, provider, priority, + fallback}`; build programs read `mcpp::target_provider()` + (`MCPP_TARGET_PROVIDER`). +- `mcpp.lock` records `[target.""]` with the identity and the + provider; `--locked` refuses a build that selects another identity. +- `[platform.replace]` and `[target..replace]` replace the row's + defaults (`-march=`, `-mabi=`, `-mcmodel=`, its flags, `-m `) by + name; a replacement that meets no word of the row is reported (mcpp#911 G2). +- A sysroot or a provider that states a hosted C environment turns off + `-ffreestanding` (G3); a platform's `link.driver = "compiler"` links any row + (G4); an artifact for another system is not judged by this machine's loader + (G5); a target-independent source artifact serves every target (G6). +- `[defaults.""] = `: a package's default + provider of a capability it requires, taken when neither the graph nor the + platform provides it (G7). +- A patch names files from the package's root; `patch_root` names another + directory. `mcpp patch edit ` and `mcpp patch save ` + edit a dependency's sources and save the change as the edge's next patch + (G8). +- `[targets..build]` compiles the shared sources an artifact links with + its own defines and flags, as the artifact's variant (G9). +- A row a platform defines is packed and carries as a built-in row does; + `--systems all` adds the systems the platforms define (G10). An artifact for + another system is built, not run, until a runner -- a runner package's tool + -- or an execution host states how it runs (G11). +- The row `i686-none-elf` (G12). +- clang pairs with the MinGW-w64 payload for `x86_64-windows-gnu`: the payload + is clang's sysroot and libstdc++ its C++ library, `import std` included. +- `mcpp new --template platform` and `mcpp check platform`. +- `mcpp pack --all-programs` ships every program of a package. +- A git reference that names no tag, rev or branch follows the remote's + default branch; `--platform git+[#tag=|rev=|branch=]`. + +### Changed + +- A request that names only a system takes the first available identity of + that system: `x86_64-windows` on a host without MSVC is + `x86_64-windows-musl` (openkal-platform), no longer `x86_64-windows-gnu`; + `aarch64-macos` and `x86_64-macos` on another host are the `-musl` + identities; `aarch64-linux` on an x86_64 host is `aarch64-linux-musl`. An + identity written whole is not replaced. +- The identity follows the C library: a project whose graph supplies musl + builds `x86_64-linux` as `x86_64-linux-musl`; an explicit identity that + disagrees with the graph's C library is refused (`target-cabi-conflict`). +- A git dependency names at most one of rev, tag and branch. +- `mcpp pack` with several programs packs the one named after the package and + names the others, and refuses when none is so named. +- mcpp's own unit tests are mtest cases (mtest 0.5.0, mcpplibs.os 0.4.1); a + skeleton's tests use mtest 0.5.0. + +### Fixed + +- A manifest `mcpp pack` generates quotes a target name with a dot (B1). +- A platform's artifact names stand over a build program's `artifact-name`; + the conflict is reported (B2). +- `mcpp pack` no longer packs one program of several without a word (B3). +- A static ELF is not run to learn its closure. + ## [2026.11.8.1] - 2026-11-08 ### Added diff --git a/mcpp.toml b/mcpp.toml index 087ea4986..14402f542 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.11.8.1" +version = "2026.11.9.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index 3b234ff55..45773e7a9 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.11.8.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.11.9.1"; } // namespace mcpp From 8db3d1289d0130886e2d86cd8947f2b8c11c8785 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 09:38:51 +0900 Subject: [PATCH 22/45] =?UTF-8?q?test:=20a=20test=20keeps=20what=20it=20re?= =?UTF-8?q?aches=20(design=202026-11-09=20=C2=A717.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Units are compiled with one section per function and per object, and a test program links with --gc-sections (-dead_strip on Mach-O): mcpp's unit test programs go from 32.6 MB to 13.7 MB each (code 17.3 MB to 1.6 MB), 5.1 GB to 2.2 GB in all. Static constructors are the collection's roots, so a case that registers itself stays; [test] link = "objects" keeps every object whole. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- modules/manifest/src/toml.cppm | 10 +++++++++- modules/manifest/src/types.cppm | 5 +++++ src/build/flags.cppm | 7 +++++++ src/build/plan.cppm | 15 +++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 23db21bd2..1dc59d3d6 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -3704,10 +3704,18 @@ std::expected parse_document(const t::Document& documen m.testPrograms = value.as_string(); continue; } + if (key == "link") { + if (!value.is_string() || (value.as_string() != "used" && value.as_string() != "objects")) + return std::unexpected(error(origin, + "[test] link is \"used\" (the linker keeps what a test reaches, the default) or " + "\"objects\" (every object whole)")); + m.testLink = value.as_string(); + continue; + } if (key != "discover") { m.schemaWarnings.push_back(std::format( "[test] has unsupported key '{}' (ignored). Supported keys: " - "discover, main, programs, start, windows_code_page, windows_long_paths.", key)); + "discover, link, main, programs, start, windows_code_page, windows_long_paths.", key)); continue; } bool ok = value.is_array(); diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 9927e0190..3c0deb5de 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -2251,6 +2251,11 @@ struct Manifest { // "directory" (the files of one directory below a discover glob's fixed // directory are one program, named by the directory). Empty: "file". std::string testPrograms; + // `[test] link` (design 2026-11-09 §17.2): "used" (the default) -- the + // linker keeps what a test reaches (`--gc-sections`, `-dead_strip`), so a + // test of one module does not carry the package's every function --, or + // "objects", every object whole. + std::string testLink; // Set once a workspace root's own package has received `[workspace.package]` // and `[workspace.build]` (`mcpp::project::inherit_as_root_package`). The // manifest is read on two paths before it is planned, and an appended diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 96b38a5e0..2a6011bb1 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -1213,6 +1213,13 @@ CompileFlags compute_flags_impl(const BuildPlan& plan, bool packageLinkFlags) { // `/LTCG` on the link and the archive; before, it was not spelled at all, // and a build with `lto = true` reported `+ lto` while doing none. if (prof.lto) opt_flag += isMsvcDialect ? " /GL" : " -flto"; + // ONE SECTION PER FUNCTION AND PER OBJECT (design 2026-11-09 §17.2), so + // that a link that keeps what it reaches (a test, `--gc-sections`) drops + // the rest of an object a module initializer pulls in. Measured on mcpp's + // unit tests: with whole objects the collection kept 15 of 17 MB of code. + // The freestanding prefix states it for its own units; cl.exe's `/Gy` is + // its default under optimisation. + if (!isMsvcDialect && !isFreestandingTarget) opt_flag += " -ffunction-sections -fdata-sections"; // `ndebug` (design 2026-10-23 §15.4): the profiles that produce what is // shipped define NDEBUG for every unit of the graph, as CMake's Release, // Meson's `b_ndebug=if-release` and Cargo's release do. diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 0c5c31880..cd65778c3 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -2039,6 +2039,19 @@ make_plan(const mcpp::manifest::Manifest& manifest, // than link.exe does, so "which compiler binary" is the wrong question. return targetOs == "windows" && mcpp::toolchain::is_msvc_target(tc); }(); + // A TEST KEEPS WHAT IT REACHES (design 2026-11-09 §17.2): every object of + // the package is on its link line, and a test of one module used to carry + // all of them -- 32 MB for each of mcpp's 166 unit tests. The linker drops + // the sections nothing references; static constructors are its roots, so + // a case that registers itself stays. `[test] link = "objects"` keeps + // every object whole. ELF and Mach-O through the compiler driver; MSVC's + // link.exe, a freestanding image and wasm keep their own link lines. + auto keep_what_tests_reach = [&](LinkUnit& lu, const mcpp::manifest::Manifest& owner) { + if (owner.testLink == "objects" || msvcTarget || targetTriple.is_freestanding()) return; + if (!targetTriple.empty() && targetTriple.str().starts_with("wasm")) return; + const bool macho = targetTriple.empty() ? bool(mcpplibs::os::is_macos) : targetTriple.is_mach_o(); + lu.linkFlags.push_back(macho ? "-Wl,-dead_strip" : "-Wl,--gc-sections"); + }; bool experimentalStd = false; if (auto stdCfg = mcpp::manifest::normalize_cpp_standard(manifest.package.standard)) { @@ -3601,6 +3614,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, lu.kind = LinkUnit::TestBinary; lu.output = target_output(t, naming); if (!t.main.empty()) lu.entryMain = projectRoot / t.main; + keep_what_tests_reach(lu, manifest); } else if (t.kind == mcpp::manifest::Target::Application && mcpp::toolchain::triple::application_form(targetTriple) == mcpp::toolchain::triple::ApplicationForm::SharedObject) { @@ -4153,6 +4167,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, : place(target_output(t, naming)); lu.windowsSubsystem = t.windowsSubsystem; lu.windowsEntry = t.windowsEntry; + if (lu.kind == LinkUnit::TestBinary) keep_what_tests_reach(lu, pkg.manifest); } lu.loaderTagFlag = loader_tag_flag(lu.kind); for (auto const& other : plan.linkUnits) From 45bfae9f03fab7dcd61343d07daa3acccf56c309 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 10:28:53 +0900 Subject: [PATCH 23/45] test: a protocol program runs as a process per file of its cases; mcpp's unit tests are one program - `[test] split`: "file" (default) lists a protocol program's cases and runs each file's as a process of its own, scheduled as programs are; "case" and "none" give a process per case or one. - The marker a runner reads is taken at the version it speaks wherever it occurs: a test of the runner carries other versions as data. - mcpp's tests/unit is one program (`[test] programs = "directory"`), with three case objects renamed that shared a name across files; tests use mtest 0.5.1, whose marker survives --gc-sections. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../workflows/ci-aarch64-fresh-install.yml | 2 +- CHANGELOG.md | 10 +++- docs/05-dependencies.md | 4 +- docs/08-testing.md | 14 +++++ docs/zh/05-dependencies.md | 4 +- docs/zh/08-testing.md | 10 ++++ mcpp.toml | 3 +- modules/buildmcpp/mcpp.toml | 2 +- modules/graph/mcpp.toml | 2 +- modules/manifest/mcpp.toml | 2 +- modules/manifest/src/toml.cppm | 11 +++- modules/manifest/src/types.cppm | 4 ++ modules/toolchain-model/mcpp.toml | 2 +- modules/versioning/mcpp.toml | 2 +- src/build/execute.cppm | 54 +++++++++++++++++++ src/build/test_protocol.cppm | 12 +++-- tests/unit/test_runner_lookup.cpp | 2 +- tests/unit/test_target_static.cpp | 2 +- tests/unit/test_toolchain_msvc.cpp | 2 +- 19 files changed, 123 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci-aarch64-fresh-install.yml b/.github/workflows/ci-aarch64-fresh-install.yml index 9625fc3ad..5fa97a62b 100644 --- a/.github/workflows/ci-aarch64-fresh-install.yml +++ b/.github/workflows/ci-aarch64-fresh-install.yml @@ -342,7 +342,7 @@ jobs: mkdir -p "$evidence/$(dirname "$relative")" cp "$binary" "$evidence/$relative" readelf -aW "$binary" > "$evidence/$relative.readelf.txt" - done < <(find target/aarch64-linux-gnu -type f -path '*/bin/unit/test_elf_runtime' -print0) + done < <(find target/aarch64-linux-gnu -type f -path '*/bin/unit' -print0) while IFS= read -r -d '' plan; do relative="${plan#target/}" mkdir -p "$evidence/$(dirname "$relative")" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ef2b923d..64e5b58cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,12 @@ another system is built, not run, until a runner -- a runner package's tool -- or an execution host states how it runs (G11). - The row `i686-none-elf` (G12). +- A test program that speaks the test protocol runs as one process per file of + its cases (`[test] split = "file" | "case" | "none"`), and a test keeps what + it reaches: units are compiled with one section per function and per object + and tests link with `--gc-sections` (`[test] link = "objects"` keeps every + object whole). mcpp's own unit tests are one program per directory: 166 + programs of 32 MB became one of 37 MB, run as 166 processes. - clang pairs with the MinGW-w64 payload for `x86_64-windows-gnu`: the payload is clang's sysroot and libstdc++ its C++ library, `import std` included. - `mcpp new --template platform` and `mcpp check platform`. @@ -72,8 +78,8 @@ - A git dependency names at most one of rev, tag and branch. - `mcpp pack` with several programs packs the one named after the package and names the others, and refuses when none is so named. -- mcpp's own unit tests are mtest cases (mtest 0.5.0, mcpplibs.os 0.4.1); a - skeleton's tests use mtest 0.5.0. +- mcpp's own unit tests are mtest cases (mtest 0.5.1, mcpplibs.os 0.4.1); a + skeleton's tests use mtest 0.5.1. ### Fixed diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index a4723d1f4..d8d02e98e 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -67,8 +67,8 @@ not deprecated: ```toml [dev-dependencies] -mcpplibs.mtest = "0.5.0" -"mcpplibs.mtest.gtest" = "0.5.0" +mcpplibs.mtest = "0.5.1" +"mcpplibs.mtest.gtest" = "0.5.1" ``` ```toml diff --git a/docs/08-testing.md b/docs/08-testing.md index 03bb0160c..28f96c5c5 100644 --- a/docs/08-testing.md +++ b/docs/08-testing.md @@ -65,6 +65,20 @@ directory stays a program of its own. A program of many files is linked once where a program per file is linked once per file, which is what a suite of many small files spends its build time on. +A program that speaks the test protocol runs as **one process per file of its +cases** (2026.11.9.1+): mcpp lists the cases, and each file's run selects its +own (P4) and is scheduled across the workers as a program is, named +`/` -- the isolation and the parallelism of a program per file, +with one link. `[test] split = "case"` gives each case its own process, and +`"none"` runs the program once. + +A test program keeps what it reaches (2026.11.9.1+): units are compiled with one +section per function and per object, and a test links with `--gc-sections` +(`-dead_strip` on Mach-O), so a test of one module does not carry its package's +every function (mcpp's own unit tests: 32 MB each to 14 MB). Static +constructors are the collection's roots. `[test] link = "objects"` keeps every +object whole. + A suite compiled from several sources of its own is also a package: a workspace member whose `[build] sources` carries the suite and whose one test program drives it, selected with `mcpp test -p `. diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 66e9ea4c9..35a17aa38 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -62,8 +62,8 @@ mcpp 把它读作这些点所拼写的选择器,警告该拼法已弃用(自 ```toml [dev-dependencies] -mcpplibs.mtest = "0.5.0" -"mcpplibs.mtest.gtest" = "0.5.0" +mcpplibs.mtest = "0.5.1" +"mcpplibs.mtest.gtest" = "0.5.1" ``` ```toml diff --git a/docs/zh/08-testing.md b/docs/zh/08-testing.md index 5668a216b..354dac3ad 100644 --- a/docs/zh/08-testing.md +++ b/docs/zh/08-testing.md @@ -57,6 +57,16 @@ discover = ["checks/**/*.cpp", "!checks/fixtures/**"] 目录中的文件仍各自是一个程序。多文件的程序只链接一次,而每文件一个程序时每个文件链接 一次,后者正是由许多小文件组成的套件在构建上花费的时间。 +使用测试协议的程序**按其用例所在文件各起一个进程**运行(2026.11.9.1+):mcpp 先列出用例, +每个文件的那次运行只选取自己的用例(P4),像一个程序一样在各工作线程间调度,名为 +`<程序>/<文件>`——兼得每文件一个程序的隔离与并行,而只链接一次。`[test] split = "case"` +使每个用例一个进程,`"none"` 只运行一次。 + +测试程序只保留它用到的部分(2026.11.9.1+):各单元以每函数、每数据对象一个节编译, +测试以 `--gc-sections` 链接(Mach-O 为 `-dead_strip`),测某一模块的测试不再带上整个包 +的全部函数(mcpp 自身的单元测试:每个由 32 MB 降至 14 MB)。静态构造是回收的根。 +`[test] link = "objects"` 保留每个目标文件的全部内容。 + 由自身多个源文件编译而成的测试套件也可以是一个包:一个工作区成员,它的 `[build] sources` 承载整套套件,其唯一的测试程序驱动套件,用 `mcpp test -p ` 选中。 diff --git a/mcpp.toml b/mcpp.toml index 14402f542..16f79ec68 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -59,6 +59,7 @@ ldflags = ["--coverage"] [test] windows_code_page = "utf-8" +programs = "directory" # Each entry equals its row's Default (SPEC-009 §12): the LLVM line moved to # 23.1.3 — the first release carrying the macOS 27 arm64e.x1 linker fix @@ -164,7 +165,7 @@ members = [ # `mcpp build` ignores [dev-dependencies]; only `mcpp test` resolves them. [dev-dependencies] -mcpplibs.mtest = "0.5.0" +mcpplibs.mtest = "0.5.1" # Everything else uses M5.0 conventions: # - target = mcpp (kind=bin inferred from src/main.cpp) diff --git a/modules/buildmcpp/mcpp.toml b/modules/buildmcpp/mcpp.toml index 539e178fa..428ccb463 100644 --- a/modules/buildmcpp/mcpp.toml +++ b/modules/buildmcpp/mcpp.toml @@ -41,4 +41,4 @@ mcpp.source-kind = { path = "../source-kind" } mcpp.toolchain-model = { path = "../toolchain-model" } [dev-dependencies] -mcpplibs.mtest = "0.5.0" +mcpplibs.mtest = "0.5.1" diff --git a/modules/graph/mcpp.toml b/modules/graph/mcpp.toml index fc6cd5cce..3213a296b 100644 --- a/modules/graph/mcpp.toml +++ b/modules/graph/mcpp.toml @@ -17,4 +17,4 @@ description = "Algorithms over index-addressed directed graphs: topological orde license = "Apache-2.0" [dev-dependencies] -mcpplibs.mtest = "0.5.0" +mcpplibs.mtest = "0.5.1" diff --git a/modules/manifest/mcpp.toml b/modules/manifest/mcpp.toml index 3656496ec..e7540d9c6 100644 --- a/modules/manifest/mcpp.toml +++ b/modules/manifest/mcpp.toml @@ -30,4 +30,4 @@ mcpplibs.os = "0.4.1" # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. [dev-dependencies] -mcpplibs.mtest = "0.5.0" +mcpplibs.mtest = "0.5.1" diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 1dc59d3d6..cec13851b 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -3704,6 +3704,15 @@ std::expected parse_document(const t::Document& documen m.testPrograms = value.as_string(); continue; } + if (key == "split") { + if (!value.is_string() || (value.as_string() != "file" && value.as_string() != "case" + && value.as_string() != "none")) + return std::unexpected(error(origin, + "[test] split is \"file\" (a process per file of a program's cases, the default), " + "\"case\" (a process per case) or \"none\" (one process)")); + m.testSplit = value.as_string(); + continue; + } if (key == "link") { if (!value.is_string() || (value.as_string() != "used" && value.as_string() != "objects")) return std::unexpected(error(origin, @@ -3715,7 +3724,7 @@ std::expected parse_document(const t::Document& documen if (key != "discover") { m.schemaWarnings.push_back(std::format( "[test] has unsupported key '{}' (ignored). Supported keys: " - "discover, link, main, programs, start, windows_code_page, windows_long_paths.", key)); + "discover, link, main, programs, split, start, windows_code_page, windows_long_paths.", key)); continue; } bool ok = value.is_array(); diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 3c0deb5de..3b8a6074a 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -2256,6 +2256,10 @@ struct Manifest { // test of one module does not carry the package's every function --, or // "objects", every object whole. std::string testLink; + // `[test] split` (design 2026-11-09 §17.3): how a test program that speaks + // the protocol is run -- "file" (the default: a process per file of its + // cases), "case" (a process per case) or "none" (one process). + std::string testSplit; // Set once a workspace root's own package has received `[workspace.package]` // and `[workspace.build]` (`mcpp::project::inherit_as_root_package`). The // manifest is read on two paths before it is planned, and an appended diff --git a/modules/toolchain-model/mcpp.toml b/modules/toolchain-model/mcpp.toml index f36fd7795..2dc3d0b3a 100644 --- a/modules/toolchain-model/mcpp.toml +++ b/modules/toolchain-model/mcpp.toml @@ -29,7 +29,7 @@ mcpplibs.os = "0.4.1" # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. [dev-dependencies] -mcpplibs.mtest = "0.5.0" +mcpplibs.mtest = "0.5.1" # build.mcpp compiles data/builtin.toml (the built-in target rows and providers) # into a module of static tables, with mcpp's own TOML parser. diff --git a/modules/versioning/mcpp.toml b/modules/versioning/mcpp.toml index 29f7994c2..f7d5b060b 100644 --- a/modules/versioning/mcpp.toml +++ b/modules/versioning/mcpp.toml @@ -25,4 +25,4 @@ license = "Apache-2.0" # state its contract in isolation; the root `tests/unit/` states the same facts # as the engine consumes them, and the pair is the cross-check. [dev-dependencies] -mcpplibs.mtest = "0.5.0" +mcpplibs.mtest = "0.5.1" diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 1e7ba955b..229aff4eb 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -5221,6 +5221,60 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, if (!json && !invocationNotRunReason.empty() && !runnable.empty() && !std::exchange(tb.invocationReasonPrinted, true)) mcpp::ui::warning(invocationNotRunReason); + // A PROGRAM OF SEVERAL FILES RUNS AS SEVERAL PROCESSES (design + // 2026-11-09 §17.3): one per file of its cases, each selecting them + // (P4), scheduled across the workers as the programs are -- the + // parallelism and the isolation of a program per file, with one link. + // `[test] split = "case"` gives each case its own process; "none" runs + // the program once. + const auto split = ctx->manifest.testSplit.empty() ? std::string("file") : ctx->manifest.testSplit; + if (invocationNotRunReason.empty() && split != "none") { + std::vector expanded; + for (auto& r : runnable) { + if (!r.protocol) { + expanded.push_back(std::move(r)); + continue; + } + mcpp::run::Options listOpts; + listOpts.env = r.env; + state_protocol(r, "list", listOpts); + listOpts.streams = mcpp::run::Streams::merged; + listOpts.total = std::chrono::milliseconds(static_cast(testOpts.timeoutSecs) * 1000); + listOpts.report_spawn_failure = false; + auto listed = mcpp::run::execute(r.argv, listOpts); + auto report = mcpp::build::test_protocol::read_report(r.protocolDir / "list.ndjson", listed.exit_code); + if (listed.exit_code != 0 || !report.done || report.listed.empty()) { + expanded.push_back(std::move(r)); // its run reports what is wrong + continue; + } + std::map> groups; + for (auto const& c : report.listed) + groups[split == "case" ? c.id : (c.file.empty() ? std::string("-") : c.file)].push_back(c.id); + if (groups.size() < 2) { + expanded.push_back(std::move(r)); + continue; + } + std::vector>> ordered(groups.begin(), groups.end()); + // The largest first, so the longest work starts first. + std::ranges::stable_sort(ordered, std::greater{}, [](auto const& g) { return g.second.size(); }); + std::size_t n = 0; + for (auto& [key, ids] : ordered) { + Runnable part = r; + const auto label = split == "case" ? key + : std::filesystem::path(key).stem().generic_string(); + part.name = std::format("{}/{}", r.name, label); + part.protocolDir = r.protocolDir / "split" / std::to_string(n++); + std::error_code ec; + std::filesystem::create_directories(part.protocolDir, ec); + std::string lines; + for (auto const& id : ids) lines += id + "\n"; + part.select = part.protocolDir / "select.txt"; + std::ofstream(*part.select, std::ios::binary) << lines; + expanded.push_back(std::move(part)); + } + } + runnable = std::move(expanded); + } run_tests_now(runnable); } summary.elapsedMs = carriedMs + std::chrono::duration_cast( diff --git a/src/build/test_protocol.cppm b/src/build/test_protocol.cppm index 80693045a..2eed5584a 100644 --- a/src/build/test_protocol.cppm +++ b/src/build/test_protocol.cppm @@ -18,7 +18,7 @@ inline constexpr int kVersion = 1; // The framework mcpp scaffolds tests with and suggests where a package has // none (docs/01, docs/08): mtest, by this release, from its repository. -inline constexpr std::string_view kMtestRelease = "0.5.0"; +inline constexpr std::string_view kMtestRelease = "0.5.1"; inline constexpr std::string_view kMtestRepo = "https://github.com/mcpp-community/mtest"; // The protocol's variables, removed from the environment a program inherits @@ -30,7 +30,9 @@ inline constexpr std::array kVariables = { // The version the marker in a program's file states (P2.3): the bytes // `mtest-protocol=` and a version followed by a byte that is not a digit; 0 -// when the file carries none or cannot be read. +// when the file carries none or cannot be read. A file may carry the bytes as +// data too (a test of a runner holds `mtest-protocol=12` among its cases), so +// the version this runner speaks is taken wherever it occurs, else the first. int marker_version(const std::filesystem::path& program); // What a program's case is, as its listing states it (P3.3). @@ -124,6 +126,7 @@ int marker_version(const std::filesystem::path& program) { constexpr std::size_t block = 1 << 20; std::string window; std::vector buffer(block); + int first = 0; while (in) { in.read(buffer.data(), static_cast(buffer.size())); const auto got = static_cast(in.gcount()); @@ -138,12 +141,13 @@ int marker_version(const std::filesystem::path& program) { if (end == at + marker.size()) continue; int version = 0; std::from_chars(window.data() + at + marker.size(), window.data() + end, version); - if (version > 0) return version; + if (version == kVersion) return version; + if (version > 0 && first == 0) first = version; } const std::size_t keep = marker.size() + 8; if (window.size() > keep) window.erase(0, window.size() - keep); } - return 0; + return first; } bool Report::failed() const { diff --git a/tests/unit/test_runner_lookup.cpp b/tests/unit/test_runner_lookup.cpp index ba53d3a31..144aca228 100644 --- a/tests/unit/test_runner_lookup.cpp +++ b/tests/unit/test_runner_lookup.cpp @@ -150,6 +150,6 @@ UTest spawn_failed_message_is_verbatim{"RunnerLookup/SpawnFailedMessageIsVerbati #else -UTest windows_covered_by_integration{"RunnerLookup/WindowsCoveredByIntegration", [] { /* passes */; }}; +UTest runner_lookup_windows_covered_by_integration{"RunnerLookup/WindowsCoveredByIntegration", [] { /* passes */; }}; #endif diff --git a/tests/unit/test_target_static.cpp b/tests/unit/test_target_static.cpp index 01643d54d..21c91976c 100644 --- a/tests/unit/test_target_static.cpp +++ b/tests/unit/test_target_static.cpp @@ -63,7 +63,7 @@ UTest pe_targets_defer_to_contract_table{"TargetSupportsFullStatic/PeTargetsDefe // ── Host target (empty triple) ────────────────────────────────────────────── // Empty means "build for this machine", so the host capability IS the answer. // This is the one case where the host constant remains correct. -UTest empty_triple_falls_back_to_host{"TargetSupportsFullStatic/EmptyTripleFallsBackToHost", [] { +UTest static_empty_triple_falls_back_to_host{"TargetSupportsFullStatic/EmptyTripleFallsBackToHost", [] { expect(bool(tc::target_supports_full_static("", true))); expect(!tc::target_supports_full_static("", false)); }}; diff --git a/tests/unit/test_toolchain_msvc.cpp b/tests/unit/test_toolchain_msvc.cpp index 2720bae9d..885b2e48f 100644 --- a/tests/unit/test_toolchain_msvc.cpp +++ b/tests/unit/test_toolchain_msvc.cpp @@ -42,7 +42,7 @@ UTest parses_arm64_and_four_component_versions{"MsvcBanner/ParsesArm64AndFourCom expect(eq(r->second, "arm64")); }}; -UTest rejects_garbage{"MsvcBanner/RejectsGarbage", [] { +UTest msvc_rejects_garbage{"MsvcBanner/RejectsGarbage", [] { expect(!(msvc::parse_cl_banner("").has_value())); expect(!(msvc::parse_cl_banner("bash: cl: command not found").has_value())); // A bare two-component number is not a cl version. From 1b6025285d78ce0eb5b59eba9f6f7a7bc15f55e1 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 10:37:21 +0900 Subject: [PATCH 24/45] target: a row's defaults are not replaced in place (mcpp#911 G2, revised) `replace` of row defaults is withdrawn: a platform whose machine differs from a built-in row defines its own row (G1), as i686-none-elf now is one (G12), and an appended flag already overrides a row's where the compiler reads the last one. `mcpp why flags` keeps naming each word's layer. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 8 +-- modules/manifest/src/platform.cppm | 21 +----- modules/manifest/src/platform_toml.cppm | 30 +------- modules/manifest/src/toml.cppm | 11 +-- modules/toolchain-model/src/model.cppm | 5 -- .../toolchain-model/src/target_schema.cppm | 34 --------- src/build/cache_key.cppm | 3 +- src/build/flag_origins.cpp | 18 ++--- src/build/flag_origins.cppm | 5 +- src/build/flags.cppm | 10 ++- src/build/prepare/retarget.cpp | 42 ----------- src/build/prepare/scan.cpp | 3 +- src/build/prepare/state.cppm | 2 - src/build/prepare/target_side.cpp | 3 +- src/build/prepare/toolchain.cpp | 1 - src/build/prepare/toolchain_decision.cpp | 1 + src/freestanding/linkline.cppm | 21 ++---- src/freestanding/target.cppm | 11 ++- src/platform/platform.cppm | 7 +- ..._replaced_and_each_flag_names_its_layer.sh | 71 ------------------- ...s_its_layer_and_a_request_its_selection.sh | 54 ++++++++++++++ tests/unit/test_freestanding.cpp | 22 ------ tests/unit/test_platform.cpp | 34 --------- 23 files changed, 89 insertions(+), 328 deletions(-) delete mode 100755 tests/e2e/1042_a_row_default_is_replaced_and_each_flag_names_its_layer.sh create mode 100755 tests/e2e/1042_each_flag_names_its_layer_and_a_request_its_selection.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 64e5b58cf..112a16094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,16 +23,14 @@ the systems and C libraries the resolved platforms define (mcpp#911 G1). - `mcpp why target` lists a request's candidates with their priorities and availability; `mcpp why flags []` lists each word of a unit's command - line and of the link line with the layer that gave it (row, replacement, - platform, toolchain, package, engine). Both take `--format json`. The build + line and of the link line with the layer that gave it (row, platform, + toolchain, package, engine; mcpp#911 G2). A row's defaults are not replaced + in place: a platform whose machine differs defines its own row (G1). Both take `--format json`. The build record and the `plan` event carry `{request, identity, provider, priority, fallback}`; build programs read `mcpp::target_provider()` (`MCPP_TARGET_PROVIDER`). - `mcpp.lock` records `[target.""]` with the identity and the provider; `--locked` refuses a build that selects another identity. -- `[platform.replace]` and `[target..replace]` replace the row's - defaults (`-march=`, `-mabi=`, `-mcmodel=`, its flags, `-m `) by - name; a replacement that meets no word of the row is reported (mcpp#911 G2). - A sysroot or a provider that states a hosted C environment turns off `-ffreestanding` (G3); a platform's `link.driver = "compiler"` links any row (G4); an artifact for another system is not judged by this machine's loader diff --git a/modules/manifest/src/platform.cppm b/modules/manifest/src/platform.cppm index 41fb000de..9ac684653 100644 --- a/modules/manifest/src/platform.cppm +++ b/modules/manifest/src/platform.cppm @@ -124,18 +124,6 @@ struct Runner { bool set = false; }; -// A word of the row's defaults and the words that stand in its place -// (`[platform.replace]`, `[target..replace]`; SPEC-016 §5.2). The -// row's defaults are its machine description -- `-march=`, `-mabi=`, -// `-mcmodel=`, its `flags` and `-m ` -- and only they are -// replaced; a convention of the engine is only added to. `with` empty: the -// word is removed. -struct Replacement { - std::string word; - std::vector with; - Origin origin; -}; - // The four flag lists of `[platform.build]` and of each of its // `[platform.build.'cfg(...)']` tables. struct Flags { @@ -308,8 +296,6 @@ struct Statement { std::vector buildCfg; // [platform.link] Link link; - // [platform.replace]: the row's defaults this statement replaces - std::vector replace; // [platform.versions] std::map versions; Origin versionsOrigin; @@ -331,8 +317,7 @@ struct Statement { && tools.empty() && build.empty() && buildCfg.empty() && !link.driver.set && !link.flags.set && !link.start.set && !link.script.set && !link.inputsSet && !link.exe.set && !link.shared.set && !link.sealSet - && versions.empty() && !hosted.set && defines.empty() && providers.empty() - && replace.empty(); + && versions.empty() && !hosted.set && defines.empty() && providers.empty(); } }; @@ -367,7 +352,7 @@ constexpr bool is_layer(std::string_view k) { inline constexpr std::string_view kPlatformKeys[] = { "targets", "toolchain", "sysroot", "runner", "runners", "carrier", "plugin", "universal", "descriptor_keys", "layers", "tools", "build", "link", "versions", "hosted", - "define", "provider", "priority", "replace", + "define", "provider", "priority", }; inline constexpr std::string_view kDefineKeys[] = { "triple", "tier", "note", "standard", "exact", "clang-triple", "object-format", "family", @@ -388,7 +373,7 @@ inline constexpr std::string_view kHostedKeys[] = { "targets", "toolchain", "lay // override a platform. `runner`, `runners`, `toolchain` and `sysroot` are the // table's existing keys and keep their parsers. inline constexpr std::string_view kOverrideKeys[] = { - "platform", "priority", "layers", "tools", "carrier", "link", "replace", + "platform", "priority", "layers", "tools", "carrier", "link", }; // The carrier entry of `s` that serves `pair` (`-`): the first whose diff --git a/modules/manifest/src/platform_toml.cppm b/modules/manifest/src/platform_toml.cppm index e1fc385b1..8a1788cfc 100644 --- a/modules/manifest/src/platform_toml.cppm +++ b/modules/manifest/src/platform_toml.cppm @@ -46,10 +46,6 @@ struct Reader { std::expected link(const mcpp::libs::toml::Table& t, std::string_view where, Link& out) const; - // `replace = { "" = "" | ["", ...] | [] }`. - std::expected - replace(const mcpp::libs::toml::Value& v, std::string_view where, std::vector& out) const; - // One entry of `link.inputs`: `{ file = "..." }` or `{ from = [...], as = "..." }`. std::expected link_input(const mcpp::libs::toml::Value& e, std::string_view key) const; @@ -321,27 +317,6 @@ Reader::flags(const toml::Table& t, std::string_view where, Flags& out, return {}; } -std::expected -Reader::replace(const toml::Value& v, std::string_view where, std::vector& out) const { - if (!v.is_table()) - return std::unexpected(std::format( - "{} must be a table of = , for example \"-mcmodel=small\" = []", where)); - for (auto const& [word, with] : v.as_table()) { - const auto key = std::format("{}.\"{}\"", where, word); - Replacement r{ word, {}, at(with, key) }; - if (with.is_string()) { - if (!with.as_string().empty()) r.with.push_back(with.as_string()); - } else { - auto w = strings(with, key); - if (!w) return std::unexpected(w.error()); - r.with = std::move(*w); - } - std::erase_if(out, [&](const Replacement& e) { return e.word == r.word; }); - out.push_back(std::move(r)); - } - return {}; -} - std::expected Reader::link_input(const toml::Value& e, std::string_view key) const { if (!e.is_table()) @@ -754,8 +729,6 @@ std::expected Reader::statement(const toml::Table& t) co if (!v.is_table()) return std::unexpected(std::format("{} must be a table", key)); if (auto r = link(v.as_table(), "[platform.link]", s.link); !r) return std::unexpected(r.error()); - } else if (k == "replace") { - if (auto r = replace(v, "[platform.replace]", s.replace); !r) return std::unexpected(r.error()); } else if (k == "versions") { if (!v.is_table()) return std::unexpected(std::format("{} must be a table of = \"\"", key)); @@ -868,8 +841,7 @@ Reader::override_(const toml::Table& t, std::string_view selector) const { } else if (k == "link") { if (!v.is_table()) return std::unexpected(std::format("{} must be a table", key)); if (auto r = link(v.as_table(), key, o.fields.link); !r) return std::unexpected(r.error()); - } else if (k == "replace") { - if (auto r = replace(v, key, o.fields.replace); !r) return std::unexpected(r.error()); + } else if (k == "runner") { auto r = runner(v, key); if (!r) return std::unexpected(r.error()); diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index cec13851b..fdbd606fd 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -4300,13 +4300,6 @@ std::expected parse_document(const t::Document& documen "[target.{}].priority must be a table, e.g. [target.{}.priority] mingw = 200", triple, triple))); platformKeys = true; } - // The row's defaults this selector replaces (SPEC-016 §5.2). - if (auto it = body.find("replace"); it != body.end()) { - if (!it->second.is_table()) - return std::unexpected(error(origin, std::format( - "[target.{}].replace must be a table, e.g. [target.{}.replace] \"-mcmodel=small\" = []", triple, triple))); - platformKeys = true; - } if (platformKeys || body.contains("runner") || body.contains("runners")) { mcpp::manifest::platform::Reader rd{origin, mcpp::manifest::platform::Tier::Manifest, &m.schemaWarnings}; @@ -4359,7 +4352,7 @@ std::expected parse_document(const t::Document& documen static constexpr std::string_view kKnownTargetTables[] = { "abi", "build", "build-dependencies", "carrier", "dependencies", "dev-dependencies", "feature-deps", "feature-requires-abi", - "feature-xlings", "layers", "link", "priority", "replace", "requires_abi", "runners", "runtime", + "feature-xlings", "layers", "link", "priority", "requires_abi", "runners", "runtime", "targets", "tools", "xlings", }; for (auto& [key, value] : body) { @@ -4395,7 +4388,7 @@ std::expected parse_document(const t::Document& documen "[target.{}] has unsupported key '{}' (ignored). Supported keys: " "cxx_runtime, linkage, min_api_level, platform, run_under, runner, sysroot, " "toolchain, plus the [target..runners] table for named runners and " - "the platform's layers, tools, carrier, link, priority and replace tables. " + "the platform's layers, tools, carrier, link and priority tables. " "Per-role contracts go in [build].cxx_runtime's table form.", triple, key)); } diff --git a/modules/toolchain-model/src/model.cppm b/modules/toolchain-model/src/model.cppm index 50c40934c..50473e5ca 100644 --- a/modules/toolchain-model/src/model.cppm +++ b/modules/toolchain-model/src/model.cppm @@ -4,7 +4,6 @@ export module mcpp.toolchain.model; import std; import mcpp.toolchain.triple; -export import mcpp.toolchain.target_schema; export import mcpp.toolchain.base; import mcpp.toolchain.platform; @@ -124,10 +123,6 @@ struct Toolchain { // true`, gives a bare-metal row a C library with `main`; `-ffreestanding` is // then not added and `main` is not treated as freestanding. bool hostedC = false; - // THE ROW'S DEFAULTS THE PLATFORM AND THE PROJECT REPLACE (mcpp#911 G2; - // SPEC-016 §5.2): `[platform.replace]` and `[target..replace]`, - // merged in their order. Read by every producer of the row's words. - std::vector rowReplace; // THE PAYLOAD A PAIRED PROVIDER SUPPLIES THE SYSTEM FROM (SPEC-016 §6): // clang aimed at `x86_64-windows-gnu` takes the MinGW-w64 payload as its // sysroot, C library and C++ runtime (libstdc++). Empty otherwise. diff --git a/modules/toolchain-model/src/target_schema.cppm b/modules/toolchain-model/src/target_schema.cppm index e12b4262f..a36e0a4a1 100644 --- a/modules/toolchain-model/src/target_schema.cppm +++ b/modules/toolchain-model/src/target_schema.cppm @@ -104,39 +104,5 @@ constexpr bool available_on(const Provider& p, std::string_view host, std::strin return false; } -// A WORD OF A ROW'S DEFAULTS AND THE WORDS THAT STAND IN ITS PLACE (SPEC-016 -// §5.2). A row's defaults are its machine description: `-march=`, `-mabi=`, -// `-mcmodel=`, its `flags`, and `-m ` of a link by ld.lld itself. A -// platform or a project replaces one by naming it as the row gives it; `with` -// empty removes it. The engine's conventions are not replaced, only added to. -// `origin` names the statement, for the reports. -struct Replacement { - std::string word; - std::vector with; - std::string origin; -}; - -// `words` with each replacement applied, in order. `met`, when given, receives -// the words of the replacements that met a word of the list. -std::vector replaced(std::span words, - std::span replace, - std::set* met = nullptr); - } // namespace mcpp::toolchain::target -namespace mcpp::toolchain::target { - -std::vector replaced(std::span words, - std::span replace, - std::set* met) { - std::vector out; - for (auto const& w : words) { - auto r = std::ranges::find(replace, w, &Replacement::word); - if (r == replace.end()) { out.push_back(w); continue; } - if (met) met->insert(w); - out.insert(out.end(), r->with.begin(), r->with.end()); - } - return out; -} - -} // namespace mcpp::toolchain::target diff --git a/src/build/cache_key.cppm b/src/build/cache_key.cppm index 07f87dd86..41796283c 100644 --- a/src/build/cache_key.cppm +++ b/src/build/cache_key.cppm @@ -494,8 +494,7 @@ BuildAxes build_axes(const mcpp::toolchain::Toolchain& tc, if (auto ft = mcpp::toolchain::triple::parse(tc.targetTriple)) if (auto spec = mcpp::freestanding::resolve(*ft)) b.targetImpliedFlags = - mcpp::freestanding::compile_flags(*spec, tc.targetCxxRuntime, tc.hostedC, - tc.rowReplace); + mcpp::freestanding::compile_flags(*spec, tc.targetCxxRuntime, tc.hostedC); b.stdlibId = tc.stdlibId; b.stdlibVersion = tc.stdlibVersion; diff --git a/src/build/flag_origins.cpp b/src/build/flag_origins.cpp index 0c4719fa4..c6c3a4579 100644 --- a/src/build/flag_origins.cpp +++ b/src/build/flag_origins.cpp @@ -15,7 +15,6 @@ import mcpp.build.compile_commands; import mcpp.manifest.flag_words; import mcpp.toolchain.model; import mcpp.toolchain.triple; -import mcpp.toolchain.target_schema; import mcpp.freestanding.target; import mcpp.platform; @@ -23,7 +22,6 @@ namespace mcpp::build { namespace { -namespace ts = mcpp::toolchain::target; // The options whose value is the next word: one word of the explanation. constexpr std::string_view kTakesValue[] = { @@ -46,9 +44,8 @@ std::vector joined(const std::vector& tokens) { } struct Layers { - // The row's words as the line has them, each with the replacement it - // came from (null: the row's own). - std::map row; + // The row's machine description, as the line has it. + std::set row; std::string rowName; std::set platform; std::string platformName; @@ -63,11 +60,7 @@ Layers layers_of(const BuildPlan& plan) { if (auto spec = mcpp::freestanding::resolve(tc.targetTriple)) { auto words = mcpp::freestanding::row_words(*spec); for (auto& w : mcpp::freestanding::row_link_words(*spec)) words.push_back(std::move(w)); - for (auto const& w : words) { - auto r = std::ranges::find(tc.rowReplace, w, &ts::Replacement::word); - if (r == tc.rowReplace.end()) { l.row.emplace(w, nullptr); continue; } - for (auto const& x : r->with) l.row.emplace(x, &*r); - } + l.row.insert(words.begin(), words.end()); } if (plan.platform.active) { l.platformName = plan.platform.display(); @@ -85,10 +78,7 @@ Layers layers_of(const BuildPlan& plan) { } FlagWord attribute(const Layers& l, const std::string& w) { - if (auto r = l.row.find(w); r != l.row.end()) { - if (!r->second) return { w, "row", l.rowName }; - return { w, "replace", std::format("{}, in place of `{}`", r->second->origin, r->second->word) }; - } + if (l.row.contains(w)) return { w, "row", l.rowName }; if (l.platform.contains(w)) return { w, "platform", l.platformName }; for (auto const& root : l.toolchainRoots) { auto generic = w; diff --git a/src/build/flag_origins.cppm b/src/build/flag_origins.cppm index 703ed1a2f..453b87741 100644 --- a/src/build/flag_origins.cppm +++ b/src/build/flag_origins.cppm @@ -8,8 +8,7 @@ // package's by construction. The configuration's flags are one string the // engine composes from the row, the platform, the toolchain and its own // conventions, and a word of it is attributed to the first of these that -// gives it: the row's machine description (after the replacements stated for -// it), the platform's `[platform.build]` and `[platform.link]`, a path inside +// gives it: the row's machine description, the platform's `[platform.build]` and `[platform.link]`, a path inside // the toolchain's payload, and otherwise the engine. A word two layers give // is the first one's. @@ -23,7 +22,7 @@ export namespace mcpp::build { struct FlagWord { std::string word; - std::string layer; // compiler | row | replace | platform | toolchain | package | engine + std::string layer; // compiler | row | platform | toolchain | package | engine std::string detail; // the row, the statement, the package, the toolchain }; diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 2a6011bb1..00ff86647 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -2470,11 +2470,10 @@ CompileFlags compute_flags_impl(const BuildPlan& plan, bool packageLinkFlags) { if (auto spec = mcpp::freestanding::resolve( *mcpp::toolchain::triple::parse(plan.toolchain.targetTriple))) { const auto prefix = mcpp::freestanding::compile_prefix( - *spec, plan.toolchain.targetCxxRuntime, plan.toolchain.hostedC, - plan.toolchain.rowReplace); + *spec, plan.toolchain.targetCxxRuntime, plan.toolchain.hostedC); f.cxx += prefix; f.cc += prefix; - f.as += mcpp::freestanding::assemble_prefix(*spec, plan.toolchain.rowReplace); + f.as += mcpp::freestanding::assemble_prefix(*spec); // ── The target's C library, from the TARGET, not from a package ── // @@ -2536,11 +2535,10 @@ CompileFlags compute_flags_impl(const BuildPlan& plan, bool packageLinkFlags) { && plan.platform.fields.link.driver.value == "compiler"; if (!spec->lldEmulation.empty() && !platformCompilerDriver) { f.ldDriver = in.lld; - fsLd = mcpp::freestanding::link_flags_direct(*spec, in, ninjaEsc, - plan.toolchain.rowReplace); + fsLd = mcpp::freestanding::link_flags_direct(*spec, in, ninjaEsc); } else { fsLd = isClangWithCfg ? " --no-default-config" : ""; - fsLd += mcpp::freestanding::link_flags(*spec, in, ninjaEsc, plan.toolchain.rowReplace); + fsLd += mcpp::freestanding::link_flags(*spec, in, ninjaEsc); } f.ld = fsLd + user_ldflags; f.ldC = f.ld; diff --git a/src/build/prepare/retarget.cpp b/src/build/prepare/retarget.cpp index 0e0993463..2a721ebfc 100644 --- a/src/build/prepare/retarget.cpp +++ b/src/build/prepare/retarget.cpp @@ -430,48 +430,6 @@ step2_retarget_for_retargetable_driver(PrepareState& state) { return {}; } -// THE ROW'S DEFAULTS, AS THE PLATFORM AND THE PROJECT REPLACE THEM (mcpp#911 -// G2; SPEC-016 §5.2). `[platform.replace]` and `[target..replace]`, -// merged in the order of the statement every reader uses; each names a word -// the row gives. One that meets no word of the row is reported with the -// row's words, since a replacement that does nothing is a mistake the build -// cannot otherwise show; each one that applies is reported with its origin. -void step2_row_replacements(PrepareState& state) { - namespace ts = mcpp::toolchain::target; - state.tc->rowReplace.clear(); - const auto stmt = platform_statement(state); - if (stmt.replace.empty()) return; - for (auto const& r : stmt.replace) { - auto origin = mcpp::platform::origin_text(r.origin); - if (r.origin.tier == mcpp::manifest::platform::Tier::Platform && state.platform.active) - origin = state.platform.display(); - state.tc->rowReplace.push_back(ts::Replacement{ r.word, r.with, std::move(origin) }); - } - std::vector words; - if (auto spec = mcpp::freestanding::resolve(state.tc->targetTriple)) { - words = mcpp::freestanding::row_words(*spec); - for (auto& w : mcpp::freestanding::row_link_words(*spec)) words.push_back(std::move(w)); - } - std::set met; - (void)ts::replaced(words, state.tc->rowReplace, &met); - std::string given; - for (auto const& w : words) given += std::format("{}`{}`", given.empty() ? "" : ", ", w); - for (auto const& r : state.tc->rowReplace) - if (!met.contains(r.word)) - mcpp::diag::warning("target/replace", std::format( - "{} replaces `{}`, which the row {} does not give (ignored); its words are {}", - r.origin, r.word, state.tc->targetTriple, given.empty() ? std::string("none") : given)); - // In the row's order, as the command line has them. - for (auto const& w : words) { - auto r = std::ranges::find(state.tc->rowReplace, w, &ts::Replacement::word); - if (r == state.tc->rowReplace.end()) continue; - std::string with; - for (auto const& x : r->with) with += std::format("{}`{}`", with.empty() ? "" : " ", x); - mcpp::ui::info("Replaces", std::format("`{}` → {} ({})", w, - with.empty() ? std::string("nothing") : with, r->origin)); - } -} - // THE SYSTEM A PAIRED PROVIDER SUPPLIES (SPEC-016 §6). The MinGW-w64 provider // pairs with gcc and with llvm; with llvm, the payload its row names is // installed as for gcc and becomes clang's sysroot: the C library, libstdc++ diff --git a/src/build/prepare/scan.cpp b/src/build/prepare/scan.cpp index ccb960316..a85f9d581 100644 --- a/src/build/prepare/scan.cpp +++ b/src/build/prepare/scan.cpp @@ -589,8 +589,7 @@ step11_package_std_module_source(PrepareState& state) { if (auto fs = mcpp::toolchain::triple::parse(state.tc->targetTriple); fs && fs->is_freestanding()) { if (auto spec = mcpp::freestanding::resolve(*fs)) - flags += mcpp::freestanding::compile_prefix(*spec, true, state.tc->hostedC, - state.tc->rowReplace); + flags += mcpp::freestanding::compile_prefix(*spec, true, state.tc->hostedC); } else if (!state.tc->crossTargetFlag.empty()) { // `crossTargetFlag` and not `targetTriple`. The triple is mcpp's // vocabulary (`aarch64-macos`); the flag carries the spelling a diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index 21f7730e1..5d927783c 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -1276,8 +1276,6 @@ std::vector platform_unit_cxxflags(const PrepareState& state); std::expected step13_finish_platform(PrepareState& state, BuildContext& ctx); // retarget.cpp: a retargetable driver told its target, sysroot and multilib. std::expected step2_retarget_for_retargetable_driver(PrepareState& state); -// The row's defaults the platform and the project replace (SPEC-016 §5.2). -void step2_row_replacements(PrepareState& state); // The system a paired provider supplies to the declared compiler (SPEC-016 §6). std::expected step2_paired_system(PrepareState& state); // The platform root a first pass asks the second to read in P1, once. diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp index dddfed41f..9e5389acd 100644 --- a/src/build/prepare/target_side.cpp +++ b/src/build/prepare/target_side.cpp @@ -571,8 +571,7 @@ step9_resolve_and_realise_cabi(PrepareState& state, TargetSideGather& gather) { if (spec) { freestandingFlags.push_back( "--target=" + std::string(spec->triple)); - for (auto const& f : mcpp::freestanding::compile_flags( - *spec, false, false, state.tc->rowReplace)) + for (auto const& f : mcpp::freestanding::compile_flags(*spec)) freestandingFlags.push_back(f); } } diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index 9d54e704d..9efad92f8 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -2294,7 +2294,6 @@ std::expected phase2_define_toolchain_resolver(PrepareState& if (auto r = step2_detect_toolchain(state); !r) return std::unexpected(r.error()); if (auto r = step2_apply_local_toolchain(state); !r) return std::unexpected(r.error()); if (auto r = step2_retarget_for_retargetable_driver(state); !r) return std::unexpected(r.error()); - step2_row_replacements(state); if (auto r = step2_paired_system(state); !r) return std::unexpected(r.error()); if (auto r = step2_bind_msvc_toolset(state); !r) return std::unexpected(r.error()); if (auto r = step2_resolve_base(state); !r) return std::unexpected(r.error()); diff --git a/src/build/prepare/toolchain_decision.cpp b/src/build/prepare/toolchain_decision.cpp index f589ab95d..d3a4fa5a9 100644 --- a/src/build/prepare/toolchain_decision.cpp +++ b/src/build/prepare/toolchain_decision.cpp @@ -21,6 +21,7 @@ import mcpp.toolchain.registry; import mcpp.toolchain.linkmodel; import mcpp.toolchain.triple; import mcpp.toolchain.target_rows; +import mcpp.toolchain.target_schema; // For `resolve_version_match` / `list_installed_versions`: a bare compiler // family named by the dependency graph resolves to a concrete version through // exactly the path `mcpp toolchain default ` uses. diff --git a/src/freestanding/linkline.cppm b/src/freestanding/linkline.cppm index 5d7be51e6..dddeb3b9d 100644 --- a/src/freestanding/linkline.cppm +++ b/src/freestanding/linkline.cppm @@ -32,7 +32,6 @@ export module mcpp.freestanding.linkline; import std; import mcpp.freestanding.target; -import mcpp.toolchain.target_schema; export namespace mcpp::freestanding { @@ -45,20 +44,16 @@ export namespace mcpp::freestanding { // wanted. Omit it and the build silently produces host objects — measured, and // the exact shape of the E1 defect this work exists to close. inline std::string compile_prefix(const Spec& s, bool targetCxxRuntime = false, - bool hostedC = false, - std::span replace = {}) { + bool hostedC = false) { std::string out; out += " --target=" + std::string(s.triple); - for (auto const& f : compile_flags(s, targetCxxRuntime, hostedC, replace)) { out += ' '; out += f; } + for (auto const& f : compile_flags(s, targetCxxRuntime, hostedC)) { out += ' '; out += f; } return out; } // Assembly goes through the C driver, which needs the same target selection // but none of the C++ dialect flags. -inline std::string assemble_prefix(const Spec& s, - std::span replace = {}) { - return compile_prefix(s, false, false, replace); -} +inline std::string assemble_prefix(const Spec& s) { return compile_prefix(s); } // What the link line must NOT do, spelled as flags: // -nostdlib no C runtime, no default libraries @@ -83,8 +78,7 @@ struct LinkInputs { inline std::string link_flags(const Spec& s, const LinkInputs& in, const std::function& esc, - std::span replace = {}) + const std::filesystem::path&)>& esc) { std::string out; out += " --target=" + std::string(s.triple); @@ -94,7 +88,7 @@ inline std::string link_flags(const Spec& s, const LinkInputs& in, // two flags the argument governs — `-fno-exceptions`, `-fno-rtti` — say // nothing to a linker. Passing it would change no byte of the output while // suggesting it does. - for (auto const& f : compile_flags(s, false, false, replace)) { out += ' '; out += f; } + for (auto const& f : compile_flags(s)) { out += ' '; out += f; } out += " -nostdlib -nostartfiles -static"; // The link half of the pair set in mcpp.freestanding.target's // `compile_flags`. Routed through the driver, hence `-Wl,`. @@ -131,11 +125,10 @@ inline std::string link_flags(const Spec& s, const LinkInputs& in, // a link that silently drops symbols is not the same link. inline std::string link_flags_direct(const Spec& s, const LinkInputs& in, const std::function& esc, - std::span replace = {}) + const std::filesystem::path&)>& esc) { std::string out; - for (auto const& w : mcpp::toolchain::target::replaced(row_link_words(s), replace)) { + for (auto const& w : row_link_words(s)) { out += ' '; out += w; } diff --git a/src/freestanding/target.cppm b/src/freestanding/target.cppm index 7b02dbb23..b122e9e90 100644 --- a/src/freestanding/target.cppm +++ b/src/freestanding/target.cppm @@ -32,7 +32,6 @@ export module mcpp.freestanding.target; import std; import mcpp.toolchain.triple; import mcpp.toolchain.builtin_targets; -import mcpp.toolchain.target_schema; export namespace mcpp::freestanding { @@ -134,9 +133,8 @@ inline std::optional resolve(std::string_view triple) { // case and named it as the point at which the exception/RTTI pair stops being // unconditional; the caller answers it from the capability the graph declares, // so the answer is the graph's rather than a guess about the target. -// THE ROW'S OWN WORDS: its machine description, which a platform or a project -// may replace (SPEC-016 §5.2); everything `compile_flags` adds after them is the -// engine's convention. +// THE ROW'S OWN WORDS: its machine description; everything `compile_flags` adds +// after them is the engine's convention (`mcpp why flags` tells them apart). inline std::vector row_words(const Spec& s) { std::vector out; out.emplace_back(std::string("-march=") + std::string(s.march)); @@ -159,9 +157,8 @@ inline std::vector row_link_words(const Spec& s) { inline std::vector compile_flags(const Spec& s, bool targetCxxRuntime = false, - bool hostedC = false, - std::span replace = {}) { - std::vector out = mcpp::toolchain::target::replaced(row_words(s), replace); + bool hostedC = false) { + std::vector out = row_words(s); // ONE SECTION PER FUNCTION, SO THE LINKER CAN DROP WHAT NOTHING CALLS. // // These two flags do nothing on their own; they are the half of diff --git a/src/platform/platform.cppm b/src/platform/platform.cppm index 46ba8cb98..9976a6395 100644 --- a/src/platform/platform.cppm +++ b/src/platform/platform.cppm @@ -86,7 +86,7 @@ std::expected select_host(std::string_view flagOrEnv, bo // concatenate (the platform's first, so a convention cannot be removed, only // added to), and `link.inputs` and `link.seal` are replaced whole (they are // one sequence each). Maps (`layers`, `tools`, `runners`, `versions`) are -// merged key by key, and so are the replacements of a row's words (`replace`). +// merged key by key. void merge(mcpp::manifest::platform::Statement& base, const mcpp::manifest::platform::Statement& over); // The part of a platform that applies when a host triple selects it: its @@ -397,11 +397,6 @@ void merge(mp::Statement& b, const mp::Statement& o) { b.link.sealOrigin = o.link.sealOrigin; b.link.sealSet = true; } - // A replacement of a row's word, by word: the later statement's stands. - for (auto const& r : o.replace) { - std::erase_if(b.replace, [&](const mp::Replacement& e) { return e.word == r.word; }); - b.replace.push_back(r); - } for (auto const& [k, v] : o.versions) b.versions[k] = v; if (!o.versions.empty()) b.versionsOrigin = o.versionsOrigin; if (o.hosted.set) { diff --git a/tests/e2e/1042_a_row_default_is_replaced_and_each_flag_names_its_layer.sh b/tests/e2e/1042_a_row_default_is_replaced_and_each_flag_names_its_layer.sh deleted file mode 100755 index 8ad5288ef..000000000 --- a/tests/e2e/1042_a_row_default_is_replaced_and_each_flag_names_its_layer.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# requires: elf llvm -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1042 -- the row's defaults are replaced by name, and every flag names its layer (mcpp#911 G2; -# SPEC-016 §4.5, §5.2). -# -# `[target..replace]` names a word the row gives (`-march=`, `-mcmodel=`, its flags, -# `-m `) and the words in its place; an empty list removes it, and a word the row does -# not give is reported with the row's words. `mcpp why flags ` lists each word of the unit's -# command line with the layer that gave it, and `mcpp why target` the request's selection. -# -# Criteria: the compile line has `-march=x86-64-v2` and no `-mcmodel=small`; the build reports each -# replacement and warns about the unmet one; `why flags` attributes the replaced word to mcpp.toml -# and `-mno-red-zone` to the row; `why target --format json` names the request, the identity and -# the candidates in order. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/src" -cat > "$t/mcpp.toml" <<'EOF' -[package] -name = "bare" -version = "0.1.0" - -[build] -sources = ["src/*.c"] - -[targets.bare] -kind = "bin" -main = "src/main.c" - -[target.x86_64-none-elf.replace] -"-mcmodel=small" = [] -"-march=x86-64" = "-march=x86-64-v2" -"-mno-such" = [] -EOF -cat > "$t/src/main.c" <<'EOF' -void _start(void) { __asm__ volatile("mov $60, %eax\n mov $7, %edi\n syscall"); } -EOF -cd "$t" - -out=$("$MCPP" build --target x86_64-none-elf 2>&1) || fail "the build did not succeed" "$out" -grep -q 'Replaces `-march=x86-64` → `-march=x86-64-v2` (mcpp.toml:' <<<"$out" || fail "the replacement is not reported" "$out" -grep -q 'Replaces `-mcmodel=small` → nothing' <<<"$out" || fail "the removal is not reported" "$out" -grep -q "replaces \`-mno-such\`, which the row x86_64-none-elf does not give" <<<"$out" \ - || fail "a replacement that meets no word is not reported" "$out" -ninja=$(find target/x86_64-none-elf -name build.ninja | head -1) -cflags=$(grep '^cflags' "$ninja") -grep -q -- '-march=x86-64-v2' <<<"$cflags" || fail "the replacement is not on the compile line" "$cflags" -grep -q -- '-mcmodel=small' <<<"$cflags" && fail "the removed word is still on the compile line" "$cflags" -echo " ok the row's words are replaced and removed by name, and an unmet one is reported" - -why=$("$MCPP" why flags main.c --target x86_64-none-elf 2>/dev/null) || fail "why flags failed" "$why" -grep -Eq '^ replace +-march=x86-64-v2 +\[mcpp.toml:[0-9]+, in place of `-march=x86-64`\]' <<<"$why" \ - || fail "the replaced word does not name its statement" "$why" -grep -Eq '^ row +-mno-red-zone +\[x86_64-none-elf\]' <<<"$why" || fail "the row's word does not name the row" "$why" -grep -Eq '^ engine +-ffreestanding' <<<"$why" || fail "the engine's convention does not name the engine" "$why" -echo " ok why flags names the layer of each word" - -json=$("$MCPP" why target --target x86_64-windows --format json 2>/dev/null) || fail "why target failed" "$json" -python3 - "$json" <<'PY' || fail "why target --format json does not state the selection" "$json" -import json, sys -t = json.loads(sys.argv[1])["data"]["target"] -assert t["request"] == "x86_64-windows", t -assert t["identity"] in ("x86_64-windows-msvc", "x86_64-windows-musl", "x86_64-windows-gnu"), t -ids = [c["identity"] for c in t["candidates"]] -assert ids[0] == "x86_64-windows-msvc", ids -assert any(c["identity"] == t["identity"] and c["provider"] == t["provider"] for c in t["candidates"]), t -PY -echo " ok why target states the request, the identity and the candidates in order" -echo "PASS: 1042 a row default is replaced and each flag names its layer" diff --git a/tests/e2e/1042_each_flag_names_its_layer_and_a_request_its_selection.sh b/tests/e2e/1042_each_flag_names_its_layer_and_a_request_its_selection.sh new file mode 100755 index 000000000..2063f83b4 --- /dev/null +++ b/tests/e2e/1042_each_flag_names_its_layer_and_a_request_its_selection.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# requires: elf llvm +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1042 -- every flag names its layer, and a request its selection (mcpp#911 G2; SPEC-016 §4.5). +# +# `mcpp why flags ` lists each word of the unit's command line and of the link line with the +# layer that gave it: the row's machine description, the platform, the toolchain, the package, the +# engine. `mcpp why target` lists the candidates of a request in the order the selection took them. +# +# Criteria: `why flags` attributes `-march=x86-64` and `-mno-red-zone` to the row, `-ffreestanding` +# to the engine and the package's define to the package; `why target --format json` names the +# request, the identity and the candidates in order. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +mkdir -p "$t/src" +cat > "$t/mcpp.toml" <<'EOF' +[package] +name = "bare" +version = "0.1.0" + +[build] +sources = ["src/*.c"] +cflags = ["-DBARE_APP=1"] + +[targets.bare] +kind = "bin" +main = "src/main.c" +EOF +cat > "$t/src/main.c" <<'EOF' +void _start(void) { __asm__ volatile("mov $60, %eax\n mov $7, %edi\n syscall"); } +EOF +cd "$t" + +why=$("$MCPP" why flags main.c --target x86_64-none-elf 2>/dev/null) || fail "why flags failed" "$why" +grep -Eq '^ row +-march=x86-64 +\[x86_64-none-elf\]' <<<"$why" || fail "the row's word does not name the row" "$why" +grep -Eq '^ row +-mno-red-zone +\[x86_64-none-elf\]' <<<"$why" || fail "the row's flag does not name the row" "$why" +grep -Eq '^ engine +-ffreestanding' <<<"$why" || fail "the engine's convention does not name the engine" "$why" +grep -Eq '^ package +-DBARE_APP=1 +\[bare\]' <<<"$why" || fail "the package's define does not name the package" "$why" +grep -Eq '^ row +-m elf_x86_64' <<<"$why" || fail "the link's emulation does not name the row" "$why" +echo " ok why flags names the layer of each word" + +json=$("$MCPP" why target --target x86_64-windows --format json 2>/dev/null) || fail "why target failed" "$json" +python3 - "$json" <<'PY' || fail "why target --format json does not state the selection" "$json" +import json, sys +t = json.loads(sys.argv[1])["data"]["target"] +assert t["request"] == "x86_64-windows", t +assert t["identity"] in ("x86_64-windows-msvc", "x86_64-windows-musl", "x86_64-windows-gnu"), t +ids = [c["identity"] for c in t["candidates"]] +assert ids[0] == "x86_64-windows-msvc", ids +assert any(c["identity"] == t["identity"] and c["provider"] == t["provider"] for c in t["candidates"]), t +PY +echo " ok why target states the request, the identity and the candidates in order" +echo "PASS: 1042 each flag names its layer and a request its selection" diff --git a/tests/unit/test_freestanding.cpp b/tests/unit/test_freestanding.cpp index 97d4fcfd6..54198337a 100644 --- a/tests/unit/test_freestanding.cpp +++ b/tests/unit/test_freestanding.cpp @@ -6,7 +6,6 @@ import mcpp.freestanding.target; import mcpp.freestanding.linkline; import mcpp.freestanding.runner; import mcpp.toolchain.triple; -import mcpp.toolchain.target_schema; import mcpp.provider.xlings; import mcpp.build.build_program; @@ -715,24 +714,3 @@ UTest arm32_soft_and_hard_variants_name_different_libdirs{"FreestandingTarget/Ar expect(gt(pairs, 0)) << "no architecture carries both float ABIs; the check " "above quantified over nothing"; }}; - -// THE ROW'S WORDS ARE REPLACED, THE ENGINE'S CONVENTIONS ARE NOT (SPEC-016 §5.2). -UTest a_replacement_changes_the_rows_words_only{"FreestandingTarget/AReplacementChangesTheRowsWordsOnly", [] { - auto s = resolve("x86_64-none-elf"); - expect(fatal(s.has_value())); - const std::vector r{ - { "-mcmodel=small", {}, "mcpp.toml:3" }, - { "-march=x86-64", { "-march=i686" }, "mcpp.toml:4" }, - { "-ffreestanding", {}, "mcpp.toml:5" }, // a convention: not the row's - { "-m elf_x86_64", { "-m elf_i386" }, "mcpp.toml:6" }, - }; - auto f = compile_flags(*s, false, false, r); - expect(std::ranges::find(f, std::string("-mcmodel=small")) == f.end()); - expect(std::ranges::find(f, std::string("-march=i686")) != f.end()); - expect(std::ranges::find(f, std::string("-mno-red-zone")) != f.end()); - expect(std::ranges::find(f, std::string("-ffreestanding")) != f.end()); - const auto esc = [](const std::filesystem::path& p) { return p.generic_string(); }; - const auto direct = mcpp::freestanding::link_flags_direct(*s, {}, esc, r); - expect(ne(direct.find(" -m elf_i386 "), std::string::npos)) << direct; - expect(eq(direct.find("elf_x86_64"), std::string::npos)) << direct; -}}; diff --git a/tests/unit/test_platform.cpp b/tests/unit/test_platform.cpp index 65a49a507..828edbc0d 100644 --- a/tests/unit/test_platform.cpp +++ b/tests/unit/test_platform.cpp @@ -580,40 +580,6 @@ UTest a_newer_release_is_named_only_for_the_default{"PlatformDefault/ANewerRelea expect(!mcpp::platform::newer_release_tip(builtIn, "openkal-platform", "0.24.3", "", "x")); }}; -// THE ROW'S DEFAULTS ARE REPLACED BY NAME (SPEC-016 §5.2): `[platform.replace]` -// and `[target..replace]` read a word, a list or nothing, and a later -// statement's replacement of the same word stands. -UTest a_replacement_names_a_row_word{"PlatformManifest/AReplacementNamesARowWord", [] { - auto m = parse(R"( -[package] -name = "p" -version = "1.0.0" - -[platform] -targets = ["x86_64-none-elf"] -[platform.replace] -"-mcmodel=small" = [] -"-march=x86-64" = "-march=i686" - -[target.x86_64-none-elf.replace] -"-march=x86-64" = ["-march=pentium4", "-mtune=generic"] -)"); - for (auto const& w : m.schemaWarnings) expect(false) << "unexpected warning: " << w; - auto const& r = m.platform.replace; - expect(fatal(eq(r.size(), 2u))); - auto removed = std::ranges::find(r, std::string("-mcmodel=small"), &mp::Replacement::word); - expect(fatal(removed != r.end())); - expect(removed->with.empty()); - expect(fatal(eq(m.platformOverrides.size(), 1u))); - auto merged = m.platform; - pf::merge(merged, m.platformOverrides[0].fields); - auto march = std::ranges::find(merged.replace, std::string("-march=x86-64"), &mp::Replacement::word); - expect(fatal(march != merged.replace.end())); - expect(eq(march->with, (std::vector{"-march=pentium4", "-mtune=generic"}))); - expect(eq(march->origin.tier, mp::Tier::Manifest)); - expect(eq(merged.replace.size(), 2u)); -}}; - // THE STRING FORM OF A REPOSITORY (SPEC-016 §8): `git+` with at most one of // tag, rev, branch after `#`; none is the remote's default branch. UTest a_repository_reference_has_a_string_form{"PlatformManifest/ARepositoryReferenceHasAStringForm", [] { From 1fa0939cc9d023d1278bf08434af645daf222103 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 11:01:35 +0900 Subject: [PATCH 25/45] SPEC-016; specs and docs of the round; CI findings fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SPEC-016 (targets and providers); SPEC-001 §5.5 (git references), SPEC-002 §3.4/§3.6/§3.7, SPEC-013 §1/§9.2/§11; docs 05, 08, 20, 21, 22, 35, 41, 90. - A written C library over a graph's other one is reported, not refused (SPEC-002 §3.4); the unused refusal codes are removed. - A build program's artifact-name alone yields to a platform's format; the project's and the machine's statements stand. - A test linked by a platform's ld.lld keeps its own link line; a capability default's line is an int (clang's narrowing). - e2e 299 and 746 state the selection; 1044 installs llvm itself. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../docs/2026-11-09-target-model-design.md | 24 +++- .agents/docs/README.md | 7 +- docs/05-dependencies.md | 9 +- docs/20-toolchains.md | 9 ++ docs/21-the-target-triple.md | 55 ++++---- docs/22-target-side.md | 21 +++ docs/35-platforms.md | 32 +++++ docs/90-build-from-source.md | 7 +- docs/README.md | 1 + docs/specs/README.md | 1 + docs/specs/package-identity.md | 14 +- docs/specs/platforms.md | 23 +++- docs/specs/target-side.md | 19 ++- docs/specs/targets-and-providers.md | 122 ++++++++++++++++++ docs/zh/05-dependencies.md | 6 +- docs/zh/20-toolchains.md | 7 + docs/zh/21-the-target-triple.md | 39 +++--- docs/zh/22-target-side.md | 18 +++ docs/zh/35-platforms.md | 26 ++++ docs/zh/90-build-from-source.md | 6 +- docs/zh/README.md | 1 + modules/manifest/src/toml.cppm | 2 +- src/build/plan.cppm | 6 +- src/build/prepare/platform.cpp | 10 +- src/build/prepare/target_side.cpp | 14 +- src/build/refusal.cppm | 10 -- ...1044_clang_pairs_with_the_mingw_payload.sh | 6 +- ...c_library_resolves_to_a_row_that_exists.sh | 43 +++--- ...t_target_is_target_keyed_not_host_keyed.sh | 3 + 29 files changed, 432 insertions(+), 109 deletions(-) create mode 100644 docs/specs/targets-and-providers.md diff --git a/.agents/docs/2026-11-09-target-model-design.md b/.agents/docs/2026-11-09-target-model-design.md index aa1330792..049a8f2f8 100644 --- a/.agents/docs/2026-11-09-target-model-design.md +++ b/.agents/docs/2026-11-09-target-model-design.md @@ -1,6 +1,6 @@ --- subject: target-model -status: accepted +status: active --- # 目标与提供者:统一的目标模型(结合 mcpp#911) @@ -65,7 +65,7 @@ SPEC-013 §0 规定引擎不持有任何平台的知识,§14 又规定宿主 | `hosted` | C 环境是否 hosted;sysroot 或 c-abi 提供者亦可声明 `hosted-standard-library` | G3 | | `[platform.link] driver` | 在每一行生效;`"compiler"` 交由编译器驱动加入启动文件与库 | G4 | | `execution` | 产物的执行环境(系统、加载器) | G5、G11 | -| 默认参数 | 带来源的数据;更具体的声明方以显式 `replace` 替换,否则追加 | G2 | +| 默认参数 | 行的机器描述不可替换;机器不同的目标是另一行(G1、G12);每个参数的来源由 `mcpp why flags` 写出 | G2 | | `pairs` | 可与之配对的编译器族(如 mingw 提供者:gcc、llvm) | 第 7 节 | **内置提供者。** 原生约定改写为随引擎发布的提供者:glibc payload(`*-linux-gnu`,native)、musl-gcc(`*-linux-musl`, @@ -119,13 +119,13 @@ Emscripten SDK。openkal-platform 的 hosted 部分为提供者,服务 `*-linu | G6 描述符 | 源码制品一份、与目标无关;预编译 payload 按身份的轴匹配,含 `any` 与回落;`linux`、`macosx`、`windows`、`openkal`、`none` 块作为轴组合的别名读取,已有索引条目不变;`descriptor_keys` 照旧可读,不再必需 | | G7 能力槽 | SPEC-002 的层机制推广到普通包:`requires` 加按系统的默认提供者;解析顺序为依赖图 > 平台 > 默认值。openkal-run 的清单随之改写 | | G8 补丁根 | 包根(解析后的清单所在目录)或显式 `patch_root`;`mcpp patch edit/save` | -| G9 按产物的编译变体 | `[targets.X] defines/flags` 作用于该产物的共享源码,按参数键编译变体,只在参数不同处 | +| G9 按产物的编译变体 | `[targets.X.build] defines/cflags/cxxflags` 作用于该产物链接的本包翻译单元,在 `obj/_variant//` 编译变体;`[targets.X] defines/flags` 维持只作用于入口;模块接口每包一个,不做变体 | | G10 载体与打包 | 任何已解析平台能构建的行,按平台声明作载体或打包的系统 | | G11 执行宿主 | 运行器协议:传输、运行、输出流、退出码,由 runner 包实现(VM、ssh、设备);无运行器的异系统产物为「已构建,未运行」 | | B1 | 生成的清单以 TOML 引号书写含 `.` 的目标名 | | B2 | 平台规定的产物后缀不被宿主后缀规则覆盖;冲突在规划期报告 | | B3 | 多个程序可打包时拒绝并说明,提供打包一个包的全部程序的写法 | -| 构建插件(SPEC-007) | SPEC-013 §1.2 的分工不变:行、提供者、层只能声明(解析工具链与依赖图时尚无程序可运行);`build`、`link`、`runner` 可由插件陈述,`replace` 在陈述层,来源记录 | +| 构建插件(SPEC-007) | SPEC-013 §1.2 的分工不变:行、提供者、层只能声明(解析工具链与依赖图时尚无程序可运行);`build`、`link`、`runner` 可由插件陈述,来源记录;构建程序的 `artifact-name` 不改变平台的产物格式 | ## 9. 多视角自审 @@ -141,7 +141,7 @@ Emscripten SDK。openkal-platform 的 hosted 部分为提供者,服务 `*-linu | 缓存与 ABI | `-linux-musl` 可由 musl-gcc 或 openkal-musl 提供;二者的预编译产物是否互换 | 缓存键已含工具链与平台的指纹,二者不共享缓存;索引中按身份匹配的预编译 payload 要求 C 层 ABI 兼容,openkal-musl 与 musl-gcc 的 musl 版本差异列为验证项 | | 跨平台 | 从 macOS 构建 `-linux-gnu` 需要 glibc payload 能装在 macOS 宿主上 | glibc payload 是目标数据,属 G6 的按身份匹配;在其可得之前,该身份在 macOS 宿主上不可服务,请求 `x86_64-linux` 回落到 `-musl` | | 跨平台 | macOS 无 SDK 的链接(dyld、libSystem 的存根) | 列为验证项(第 11 节);验证前 `aarch64-macos-musl` 在非 macOS 宿主上标为 preview | -| 安全 | 平台包可以定义行与替换默认参数 | 平台包本就能注入参数;`replace` 只作用于带来源的默认参数,`mcpp explain flags` 写出每个参数的来源层 | +| 安全 | 平台包可以定义行 | 平台包本就能注入参数;定义的行不能覆盖内置行;`mcpp why flags` 写出每个参数的来源层 | | 性能 | 选择不得拉长构建的准备阶段 | 内置行与提供者由构建程序编为引擎中的静态表,运行时不解析任何文件;平台包只在被选中或已解析时读取(与现在相同);候选按优先级依次检查可得性(本地 store 的存在性),取到第一个即停;结果在一次构建内缓存;`--timings` 报告选择所用时间,e2e 断言其上界 | | 用户体验 | 新增提示可能冗余 | 只在发生回落时附一句原因;其余沿用现有的「写法 → 身份」与层来源行 | | 诊断 | 拒绝时用户不知道下一步 | 第 6 节第 6 条:所需、各候选的原因、可执行的命令 | @@ -387,3 +387,17 @@ mcpp 的单元测试(`tests/unit/` 166 个文件,以及各成员的 `modules | G1 | 全部 | CI 全绿、生态级自审、合入与发布、xim-pkgindex、clean-room 验证、关闭 issue | 以上全部 | 可以并行的部分:B1、C1 的 CI 运行期间推进 A2–A8;mcpp 的 CI 运行期间推进 D1、E1。发布顺序为 os → mtest → mcpp → openkal-platform 与 openkal-image → mcppls。 + + +## 19. 实现中的取舍(2026-11-09,按“通用、必要、符合规范”评估) + +| 项 | 结论 | 理由 | +|---|---|---| +| G2 `replace` | 撤回 | 有了 G1 与 G12,机器不同的目标即另一行;追加参数时编译器取后者。替换机制是多余的核心复杂度 | +| G2 解释 | `mcpp why flags`,不新增 `explain flags` | `why` 是来源解释的既有命令族;`explain` 只解释错误码 | +| 显式身份与图冲突 | 报告,不拒绝(SPEC-002 §3.4 规则四) | 拒绝曾实测打破把宿主身份写作 `x86_64-linux-gnu` 的配置;只有未写 env 的请求随 C 库 | +| 拒绝码 | 沿用 `host-cannot-serve`、`unknown-target`、`tier-planned` | 不新增与既有码同义的 `target-unserved` 等 | +| B2 | 只约束构建程序的 `artifact-name` | 项目与本机对产物名的陈述是显式选择,照常生效 | +| G9 | 新表 `[targets.X.build]` | 现有键的作用范围不变,升级无感 | +| G8 `mcpp patch edit/save` | 保留 | 补丁是核心功能,编辑流程与之同属(同 pnpm 的 `patch`/`patch-commit`) | +| 测试程序 | 按目录组成为可选(`[test] programs`);`[test] split` 按文件分进程为缺省;测试只保留可达部分(`--gc-sections`) | 缺省不改变既有工程的程序划分与命名;按文件分进程同时保留隔离与并行 | diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 91f26e835..54b0b8728 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded --- ``` -355 records. +356 records. ## By subject @@ -138,6 +138,10 @@ Records that declare one. Everything else is listed by date below. - [#674 设计方案评审:`-include unistd.h` 在 Windows + `presents = "posix"` 上的可行性](2026-09-20-issue-674-design-review.md) — active - [`__cxa_thread_atexit` 在 openkal-Windows 上:两层都已定位并修复](2026-09-20-cxa-thread-atexit-finding.md) — landed +### target-model + +- [目标与提供者:统一的目标模型(结合 mcpp#911)](2026-11-09-target-model-design.md) — active + ### targets - [A verified Web run that asked the host for node](2026-09-12-a-verified-web-run-that-asked-the-host-for-node.md) — landed @@ -179,6 +183,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-11 +- [目标与提供者:统一的目标模型(结合 mcpp#911)](2026-11-09-target-model-design.md) — active - [生态顶层方案:#885 收尾之后的工作流、原则与依赖](2026-11-08-ecosystem-top-level-design.md) — active - [#885 收尾补充方案:目标角色、平台事实、系统接口与版本书写](2026-11-08-ecosystem-885-supplement-design.md) — active - [生态 #885 收尾设计:核心、插件、openkal、语言服务与编辑器的一轮闭合](2026-11-07-ecosystem-885-closeout-design.md) — active diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index d8d02e98e..ed9dbf71e 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -78,10 +78,11 @@ mylib = { path = "../mylib" } ``` ```toml -# Git dependency — pick exactly one of tag / branch / rev +# Git dependency — at most one of tag / branch / rev; none follows the default branch [dependencies] mylib = { git = "https://github.com/user/mylib.git", tag = "v1.0.0" } applib = { git = "https://github.com/user/applib.git", branch = "develop" } +tiplib = { git = "https://github.com/user/tiplib.git" } # the remote's default branch ``` ```toml @@ -103,6 +104,12 @@ records the same resolution (2026.10.21.1+). LF and CRLF line endings are accept without rewriting an otherwise identical file. This does not skip dependency resolution or change which dependencies the lock pins. +A git dependency that names none of `tag`, `branch` and `rev` follows the remote's +default branch, whose name the remote announces (`main`, `master`, `trunk`, ...; +2026.11.9.1+); it is a `branch` from then on, locked by commit. The same string +forms reach a platform or a tool on the command line: +`--platform git+https://example.org/acme/acme-platform.git#tag=v1`. + **Git dependencies and `mcpp.lock`**: a `tag` or `rev` already names a fixed point in history, but a `branch` moves. The first build resolves the branch to a commit and records it in `mcpp.lock`, and every later build rebuilds **that** commit — the diff --git a/docs/20-toolchains.md b/docs/20-toolchains.md index f0119782b..f4db148ee 100644 --- a/docs/20-toolchains.md +++ b/docs/20-toolchains.md @@ -307,6 +307,15 @@ Before 2026.8.3.3 a mingw build on a Windows host emitted `foo.lib` — a GNU archive wearing an MSVC name, which MSVC cannot consume. A script that globs `*.lib` out of a `windows-gnu` build, it needs to glob `*.a` now. +### clang with the MinGW-w64 payload (2026.11.9.1+) + +The MinGW-w64 provider pairs with gcc and with llvm. With llvm declared +(`--toolchain llvm@`, or `[target.x86_64-windows-gnu] toolchain`), the +payload is installed as for gcc and becomes clang's sysroot: its C library, +libstdc++ (whose `bits/std.cc` is the std module, so `import std` works) and +the GCC runtime, linked by lld. The build reports `Pairs clang ... with the +gcc@ payload`. + ## Linux ELF from Windows (`x86_64-linux-musl`, no WSL required) The mirror of the section above: a Windows machine producing a **fully static diff --git a/docs/21-the-target-triple.md b/docs/21-the-target-triple.md index 42f91e62b..e6e84b596 100644 --- a/docs/21-the-target-triple.md +++ b/docs/21-the-target-triple.md @@ -146,40 +146,43 @@ A `.js` file carries no shebang, so an emsdk payload installed before its descriptor gained a `runner` field needs the index refresh [chapter 20](20-toolchains.md) describes before `mcpp run` finds one. -## Declining The Third Segment +## Declining The Third Segment: A Request Names A System (2026.11.9.1+) -`-` is a complete target on every platform: +A target written whole is an **identity**: the output directory, the cache key +and the subject of a `cfg()` predicate. A target that names only `-` +is a **request**: it reaches every identity of that system, and the build takes +the first one whose **provider** -- the toolchain and the target side that +serve it -- is available on this machine (SPEC-016 §4). ```bash -mcpp build --target x86_64-linux # = x86_64-linux-gnu -mcpp build --target x86_64-windows # = x86_64-windows-gnu +mcpp build --target x86_64-linux # x86_64-linux-gnu on Linux; -musl where no glibc payload is +mcpp build --target x86_64-windows # -msvc with MSVC installed, else -musl (openkal-platform) +mcpp build --target aarch64-macos # the Apple identity on macOS, else aarch64-macos-musl mcpp build --target riscv64-none # = riscv64-none-elf -mcpp build --target aarch64-linux # = aarch64-linux-gnu -mcpp build --target aarch64-macos # macOS has no segment to decline ``` -Declining it changes no identity. The output directory, the cache key and the -subject of a `cfg()` predicate are all the canonical form, so the two spellings -share one fingerprint and the second build is a cache hit rather than a second -full build. +The candidates are ordered by the providers' priorities (data the engine ships: +a system's own toolchain and C library first, openkal-platform next, other +conventions after), then by the rows' order. The report says which identity +was taken, and why when the first candidate is not available here: -What differs is the record of what was asked for. A triple serves as an -identity, which must be total, and as a request, which must be able to say -nothing; mcpp keeps both, filling the segment for the identity while recording -that the fill was a fill. - -### The completion is chosen from the vocabulary, not from a fixed word - -A request with an omitted environment segment is completed against the -known-target table. A supported lexical default takes precedence; otherwise, -a unique supported sibling is selected. If none is supported, the lexical -form is retained and the diagnostic names the registered siblings. An -ambiguous request lists the supported candidates. - -`aarch64-linux` selects `aarch64-linux-gnu`. The native Linux ARM64 payload -uses LLVM 23.1.3 and glibc. `aarch64-linux-musl` remains the explicit spelling -for a static musl artifact. A written environment segment is retained. +```text + Target x86_64-windows → x86_64-windows-musl + msvc is not available on linux-x86_64 +``` +`mcpp why target [--target ]` lists the candidates in order with their +priorities and availability (`--format json` for scripts). A project changes +the order with `[target..priority] = `, or chooses a +built-in provider by name with `platform = "builtin:"`. A written +identity is never replaced: `--target x86_64-linux-gnu` builds that identity or +is refused naming what is missing. `mcpp.lock` records the identity each +request was given, and `--locked` refuses a build that selects another. + +When the dependency graph supplies the C library (`mcpp:c-abi`) and the request +names none, the identity is the C library's: `x86_64-linux` over a graph's musl +builds `x86_64-linux-musl`. A written segment the graph contradicts is reported +and kept. ### The Spelling To Use diff --git a/docs/22-target-side.md b/docs/22-target-side.md index f61b5a92c..facc2a442 100644 --- a/docs/22-target-side.md +++ b/docs/22-target-side.md @@ -297,6 +297,27 @@ The check runs before compilation begins. The combination it rejects otherwise fails inside the runtime's own headers, in a message naming a file the reader has never opened and no decision mcpp made. +### Default providers (2026.11.9.1+) + +A package that requires a capability states the providers it takes when nothing +else provides it, per target (SPEC-002 §3.6): + +```toml +[package] +requires = ["mcpp:kernel-abi=openkal"] + +[defaults."mcpp:kernel-abi=openkal"] +linux = "openkal-linux@^0.25" +macos = "openkal-macos@^0.25" +"*" = "openkal-posix@^0.25" +``` + +A provider in the graph -- the consumer's dependency, or the package the +platform adds for its layer -- is used instead; otherwise the default whose +selector matches the target joins the graph as the package's dependency, and +the build reports it (`Default ... for ... of ...`). A selector that names the +target wins over `*`. + The same statement is the recipe for a package whose install hook compiles a static library from source. The library is compiled against one C++ standard library and cannot be linked into a program that uses another, and the store diff --git a/docs/35-platforms.md b/docs/35-platforms.md index 01af845d5..c61256923 100644 --- a/docs/35-platforms.md +++ b/docs/35-platforms.md @@ -284,6 +284,38 @@ platform package, with no `--target`, builds its tests for each of its architecture and those an execution host is named for (2026.10.29.1+), and reports the others as built and not run. +## Defining targets and providers (2026.11.9.1+) + +A platform may serve a system no built-in row names. `[[platform.define]]` +states the row, `[[platform.provider]]` the provider that serves it: + +```toml +[[platform.define]] +triple = "x86_64-freebsd" +clang-triple = "x86_64-unknown-freebsd14.1" +object-format = "elf" +family = "unix" +execution = "freebsd" # the system its programs run on + +[[platform.provider]] +serves = ["x86_64-freebsd"] # patterns: `*` is any one segment +priority = 100 +hosted = true # the C environment has `main` +``` + +With the platform selected (`[target.x86_64-freebsd] platform = {...}` or +`--platform`), `--target x86_64-freebsd` builds into `target/x86_64-freebsd/`, +`cfg(os = "freebsd")` holds, and a program whose system is not this machine's +is built, not run, until a runner states how it runs (docs/41). The row's +machine description is the platform's: a target whose machine differs from a +built-in row is another row. `[platform]` and `[platform.hosted]` remain the two +shorthands of a provider. + +`mcpp new --template platform` writes a platform package defining +`-`; `mcpp check platform [dir]` reads one as the engine does -- +its rows, its providers and the identities it serves with their priorities -- +and refuses a row that redefines a built-in one. + ## Inspecting a configuration ```sh diff --git a/docs/90-build-from-source.md b/docs/90-build-from-source.md index d840b4cf1..028ecbb1b 100644 --- a/docs/90-build-from-source.md +++ b/docs/90-build-from-source.md @@ -81,7 +81,7 @@ src/ └── scaffold/ `mcpp new` and templates tests/ -├── unit/ 166 mtest programs, discovered by `mcpp test` +├── unit/ 166 files of mtest cases, one test program (`[test] programs = "directory"`) └── e2e/ 711 shell scripts against a real binary ``` @@ -94,9 +94,10 @@ are C++ programs `mcpp test` discovers. They exercise a module's contract with n binary and no filesystem state. Each is a file of [mtest](https://github.com/mcpp-community/mtest) cases: an object `UTest name{"Suite/Case", [] { ... }};` is one case, with no `main` (`mcpp test --guide`). `mcpp test --case 'TargetSelect/*'` runs the cases a pattern -names; `mcpp test -p ` runs a member's. +names; `mcpp test -p ` runs a member's. The files are one program, linked once and run as +one process per file. -**End-to-end tests** (`tests/e2e/NN_.sh`, 711 files) run a real `mcpp` +**End-to-end tests** (`tests/e2e/NN_.sh`, 721 files) run a real `mcpp` binary against a real project. `run_all.sh` is the CI entry point. `mcpp test` does **not** run them. diff --git a/docs/README.md b/docs/README.md index 0d442e937..9232b8567 100644 --- a/docs/README.md +++ b/docs/README.md @@ -178,3 +178,4 @@ downstream tooling. - [SPEC-013 — Platforms: the configuration of a class of targets, its selection, merge, graph completion, link, seal, runner and carrier](specs/platforms.md) - [SPEC-014 — Packaging and distribution: link, closure and container, legs, the staged tree, the platform's tree, run coverage and single-file packages](specs/packaging.md) - [SPEC-015 — Target sets and legs: spelling and expansion, commands over several legs, test archives](specs/target-sets.md) + - [SPEC-016 — Targets and providers: rows, providers, the selection, platform-defined rows, reports and the lock](specs/targets-and-providers.md) diff --git a/docs/specs/README.md b/docs/specs/README.md index 1e7047588..49407cd9c 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -45,6 +45,7 @@ | [SPEC-013](platforms.md) | 平台:`[platform]` 的七组键、版本组与 `hosted`,三种写法与层次,目标图与宿主图的选择,`cfg(platform)`,依赖图的补全,链接、命名与封装,运行器与载体,平台阶段与协议 17 | 评审中 v1.5 | 2026-11-07 | mcpp >= 2026.10.27.1;§10 的执行宿主与 §11 的载体架构 mcpp >= 2026.10.29.1;§7.5 版本组参与解析与 §14 平台系列 mcpp >= 2026.11.7.1;§14 内置平台为确切版本 mcpp >= 2026.11.7.2 | | [SPEC-014](packaging.md) | 打包与分发:链接、闭包、容器三个维度与构建腿,腿的合并,暂存树与 `mcpp-package.json`,平台的树、运行覆盖、单文件、polyglot、容器列表、签名、压缩、合并与分发描述 | 评审中 v2.1 | 2026-10-31 | mcpp >= 2026.10.31.1;逐条标注 | | [SPEC-015](target-sets.md) | 目标集合与腿:集合的写法与展开,命令对多条腿的语义,串行规划与一起构建,测试归档,机器输出 | 评审中 v1.0 | 2026-10-30 | mcpp >= 2026.10.30.1 | +| [SPEC-016](targets-and-providers.md) | 目标与提供者:行、提供者、选择规则、平台定义的行、C 环境与执行、报告、机器输出与锁 | 评审中 v1.0 | 2026-11-09 | mcpp >= 2026.11.9.1 | ## 文档约定 diff --git a/docs/specs/package-identity.md b/docs/specs/package-identity.md index fc754ae02..8fdaf93ee 100644 --- a/docs/specs/package-identity.md +++ b/docs/specs/package-identity.md @@ -6,7 +6,7 @@ | **标题** | 包身份(`package.namespace` / `package.name`)、`[dependencies]` 选择器与匹配机制 | | **状态** | **评审中(Review)** —— 已实现 | | **版本** | 1.7 | -| **最后修改** | 2026-10-17 | +| **最后修改** | 2026-11-09 | | **最低实现版本** | 描述符身份:mcpp **0.0.106**;精确 selector:mcpp **2026.8.10.1**(xlings >= 0.4.69) | | **作者/维护** | mcpp-community | | **相关设计文档** | `.agents/docs/2026-06-20-package-resolution-architecture.md` §4
`.agents/docs/2026-06-26-identity-first-resolution-no-filename.md`
`.agents/docs/2026-07-25-issue278-descriptor-name-form-canonicalization-design.md`
`.agents/docs/2026-07-25-name-namespace-bidirectional-verification-report.md`
`.agents/docs/2026-07-25-name-namespace-canonical-implementation-spec.md` | @@ -318,6 +318,17 @@ cancellation = { path = "modules/cancellation" }`)。告警在解析完成之后 采用而非拒绝:来源已由声明行固定,§4.2 反对候选搜索的理由(索引状态改变依赖的指向) 在这里不成立。 +### 5.5 git 引用 已实现(mcpp >= 2026.11.9.1) + +- git 来源**至多**写出 `rev`、`tag`、`branch` 之一;写出两个以上**必须**被拒绝。 +- 一个都不写时,引用为远端的**缺省分支**:名字取自远端的通告(`git ls-remote --symref HEAD`), + 远端不通告时依次尝试 `main`、`master`,报告写出所用分支;引擎**禁止**写死分支名。缺省分支按 `branch` + 处理:锁记录解析得到的提交,离线时由锁回答。 +- 命令行与环境变量中引用的字符串形式为 `[.]@<约束>`、`path:`(或路径)、 + `git+[#tag=|rev=|branch=]`、`builtin:`(只用于提供者,SPEC-016 §3)。只以 URL 指名的 + 仓库,其包名取 URL 的最后一段(去掉 `.git`),取得后的清单名按 §5.4 核对。 +- 清单中写出的引用记入锁;只在命令行或环境变量中出现的引用不改写锁。 + 清单未声明 `namespace` 时,身份的命名空间取键的命名空间(§5.3 的兼容边界)。此时同一 来源经两个命名空间不同的键到达,就是同一来源上的两个身份;实现**必须**在扫描源码之前 拒绝,点名二者。 @@ -541,6 +552,7 @@ mcpp 侧的使用: | 版本 | 日期 | 变更 | |---|---|---| +| 1.8 | 2026-11-09 | 新增 §5.5:git 引用至多一个 ref、缺省分支、命令行的字符串形式(mcpp 2026.11.9.1) | | 1.5 | 2026-09-27 | 新增 §10.1:版本位的回答与 xlings 一致(安装记录优先,其次按 xlings 版本文法选择;已记录而缺失的载荷不算已安装),mcpp#712、#716;新增 §10.2:打包修订 `revision` 的含义与 mcpp 侧的三处使用(mcpp 2026.9.27.1,xlings 2026.9.27.1) | | 1.7 | 2026-10-17 | §10.2:新增 `floor`,区分可用与当前;工具链、运行时与宿主工具按使用/供给两种意图解析,使用命令不因修订号替换可用载荷;工具链载荷的修订号进入驱动身份;替换失败的语义。§10.3 第 3 条把修订号一致移出完整性(mcpp 2026.10.17.1,xlings 2026.10.17.1,libxpkg 0.0.65) | | 1.6 | 2026-10-16 | 新增 §10.3:载荷的身份含修订号,身份相同的载荷可以经链接条目在 home 之间共用;载荷所在的 store 由服务它的索引决定;完整性由提供者回答,mcpp 不写入载荷(mcpp 2026.10.16.1,xlings 2026.10.16.1) | diff --git a/docs/specs/platforms.md b/docs/specs/platforms.md index 01885a671..8b375fcb3 100644 --- a/docs/specs/platforms.md +++ b/docs/specs/platforms.md @@ -4,9 +4,9 @@ |---|---| | 规范编号 | SPEC-013 | | 标题 | 平台:一类目标的可复用配置,其声明、选择、合并、对依赖图的补全、链接与封装、运行器与载体 | -| 状态 | 评审中 v1.5 | +| 状态 | 评审中 v1.6 | | 版本 | 1.5 | -| 最后修改 | 2026-11-07 | +| 最后修改 | 2026-11-09 | | 对应实现 | mcpp >= 2026.10.27.1;§10 的执行宿主与 §11 的载体架构 mcpp >= 2026.10.29.1;`runner.hosts`、载体列表、`descriptor_keys` 与 `universal` 的弃用 mcpp >= 2026.10.30.1;§7.5 版本组参与解析、§14 平台系列与 §15.1 系列的锁 mcpp >= 2026.11.7.1;§14 内置平台为确切版本与提示 mcpp >= 2026.11.7.2;逐条标注 | | 相关设计文档 | `.agents/docs/2026-10-28-platforms-design.md`、`.agents/docs/2026-10-28-platforms-part2-packaging-and-distribution-design.md`、`.agents/docs/2026-10-29-openkal-run-test-pack-design.md` | | 相关 issue | mcpp#846、mcpp#847、mcpp#848 | @@ -35,8 +35,12 @@ ## 1. `[platform]` 段 -一个包的清单含 `[platform]` 段时,该包是平台包。`[platform]` **必须**含 `targets`;否则清单被拒绝。 -**已实现** +一个包的清单含 `[platform]` 段时,该包是平台包。`[platform]` **必须**含 `targets` 或至少一个 +`[[platform.provider]]`;否则清单被拒绝。**已实现** + +**行与提供者的一般形式(mcpp >= 2026.11.9.1)。** `[[platform.define]]` 定义平台的行,`[[platform.provider]]` 声明 +服务行的提供者(SPEC-016 §2、§3、§5);`[platform]` 是服务 `targets` 的提供者,`[platform.hosted]` 是服务宿主三元组的 +hosted 提供者,二者为一般形式的简写,照旧可读。服务某身份的 `[[platform.provider]]` 的字段代替平台语句的同名字段。 ```toml [platform] @@ -56,6 +60,9 @@ plugin = "build.mcpp" [platform.link] driver, flags, start, script, inputs, exe, shared, seal [platform.versions] = "" [platform.hosted] targets, toolchain, layers + +[[platform.define]] triple, clang-triple, object-format, family, execution, ... (SPEC-016 §2.1) +[[platform.provider]] serves, priority, hosts, pairs, toolchain, sysroot, layers, link, hosted, base, execution ``` ### 1.1 键 @@ -280,6 +287,10 @@ plugin = "build.mcpp" | `shared` | 共享产物的名字模式,`{name}` 恰好一次,不含目录 | | `seal` | 封装步骤,每步一个 argv(§9.3) | +`driver = "compiler"` 在每一行生效,包括行本身以 `link-emulation` 直接调用 `ld.lld` 的裸机行(mcpp >= 2026.11.9.1)。 +平台的 `exe`、`shared` 中 `{name}` 之后的部分是平台的格式:构建程序的 `artifact-name` 改变它时被报告,平台的模式保留; +项目与本机的陈述照常生效(mcpp >= 2026.11.9.1)。 + `driver = "lld"` 时引擎在编译器旁依次查找 `ld.lld`、`lld`、`lld-link`(Windows 载荷只带 `lld-link`);都没有时拒绝 (`lld-required-absent`),消息写出平台的工具链。程序名不是 `ld.lld` 时引擎加 `-flavor gnu`。链接或封装命名的程序既非图中为本机构建、也不在本机可找到时拒绝 (`platform-tool-absent`),消息写出负责构建它的包,或写出 `[target.'cfg(platform = "")'.tools]` 的写法。 @@ -352,6 +363,9 @@ run_under = ["qemu-aarch64"] 需要载体的打包被拒绝并写出该键;本 mcpp 不构建程序的行(SPEC-014 §4.4)上的载体在生成工程之前被拒绝,写出系统、架构与行 (2026.10.29.1)。 +**平台定义的系统(mcpp >= 2026.11.9.1)。** `--systems` 与载体项的 `hosts` 接受任何系统词:`-` 的 +`` 为内置系统或已解析平台定义的系统,`all` 包含后者。平台定义的系统上的程序建于定义它的平台之上,为该行的静态程序。 + **载体项的选择(mcpp >= 2026.10.30.1)。** 为 `-` 构建载体程序时(上述三处与 §10 的执行宿主),所用的是 `hosts` 列出该对的第一项,否则为第一个未写 `hosts` 的项;两者都没有时,如同平台未声明 `carrier`,打包或运行被拒绝,写出 该对。`build` 不是 `"row"` 的项留给将来的构建模型(一个文件服务多个系统与架构的载体,mcpp#849),本 mcpp 读到时跳过并警告。 @@ -532,3 +546,4 @@ openkal-platform 的 `targets` 因此不含 WebAssembly。Web 经 `wasm32-emscri | 1.3 | 2026-10-30 | mcpp 2026.10.30.1(设计 2026-10-29 §4.3–§4.5、§13.3):§1.1 `universal` 弃用、不起作用;`runner.hosts`;`carrier` 接受列表,每项的 `hosts` 与 `build`;`descriptor_keys`;§10 执行宿主的运行器程序按 `hosts` 选择载体项;§11 携带映像的载体只属于单文件,树携带运行器形式;判据 13 改写(e2e 961)。 | | 1.5 | 2026-11-07 | mcpp 2026.11.7.2(设计 2026-11-07 §4.10,D20):§14 目标表的平台列为确切版本 0.24.3,锁中不满足的记录不被采用并写出;使用内置版本的构建失败且索引有更新版本时给出提示;判据 15 新增(e2e 1025)。 | | 1.4 | 2026-11-07 | mcpp 2026.11.7.1(设计 2026-11-07 §4.1.4、§4.2.2 A2/A3):§7.5 版本组参与解析,解析后的检查保留为断言;§3.2 平台引用接受 SemVer 约束;§14 目标表的平台列为系列 `^0.24`,系列取引擎可读的最新版本;§15.1 系列从锁中读回,`mcpp update` 移动它;判据 5 改写、14 新增(e2e 1020、1021)。 | +| 1.6 | 2026-11-09 | mcpp 2026.11.9.1(SPEC-016,mcpp#911):§1 `[[platform.define]]` 与 `[[platform.provider]]` 为一般形式,`[platform]`、`[platform.hosted]` 为其简写;§9.2 `driver = "compiler"` 在每一行生效,平台的产物名模式不被构建程序改变;§11 平台定义的系统上的载体与 `--systems`;主机对为任意系统词。 | diff --git a/docs/specs/target-side.md b/docs/specs/target-side.md index d5d7a232e..af25370bc 100644 --- a/docs/specs/target-side.md +++ b/docs/specs/target-side.md @@ -4,8 +4,8 @@ |---|---| | 规范编号 | SPEC-002 | | 标题 | 目标侧模型与能力声明(`mcpp:` 保留命名空间) | -| 状态 | 评审中 v1.3 | -| 最后修改 | 2026-10-26 | +| 状态 | 评审中 v1.5 | +| 最后修改 | 2026-11-09 | | 对应实现 | mcpp >= 2026.8.24.2;§2.2 的对象形式与 §7 mcpp >= 2026.10.26.1 | | 相关设计文档 | `.agents/docs/2026-08-24-target-side-design.md`、`.agents/docs/2026-10-23-target-base-and-execution-host-design.md`、`.agents/docs/2026-10-26-control-contract-runtime-and-ide-ecosystem-design.md` §6 | | 使用文档 | [docs/22 - 目标侧](../zh/22-target-side.md) | @@ -171,6 +171,20 @@ mcpp:<层名>[=<实现名>] 规范化会把 `x86_64-linux` 写成 `x86_64-linux-gnu`,因为身份必须是全的。 请求**必须**在规范化之前捕获;报告**应当**显示工程书写的拼写。 +段缺席而 `c-abi` 来自图时,身份取实际 C 库的行(`x86_64-linux` 依赖 musl 时为 +`x86_64-linux-musl`),输出目录随之(SPEC-016 §4.5;mcpp >= 2026.11.9.1)。 + +### 3.6 能力的缺省提供者 已实现(mcpp >= 2026.11.9.1) + +层的机制推广到普通包:包以 `requires` 声明所需能力,以 `[defaults."<能力>"] <选择器> = <包>` 声明缺省 +提供者。解析顺序为依赖图 > 平台 > 缺省:图中已有包提供该能力(使用者的选择,或平台为其层加入的包)时 +**禁止**加入缺省;否则取与目标匹配的选择器的缺省,`*` 在其余选择器之后。缺省提供者是声明它的包的依赖。 + +### 3.7 C 环境的声明 已实现(mcpp >= 2026.11.9.1) + +hosted 与否是 C 环境的一个声明:提供 `hosted-standard-library` 的 sysroot、`c-abi` 供给者或 `hosted = true` +的提供者使裸机行不加 `-ffreestanding`,`main` 不按 freestanding 处理。 + ### 3.5 目标表的约定何时生效 已实现 目标行的 `pin` 命名的是**供给该目标 C 库的载荷**,不是偏好的编译器。 @@ -338,4 +352,5 @@ mcpp:<层名>[=<实现名>] | v1.1 | 2026-09-26 | 规则三增补:`c-abi` 来自图时,链接不搜索宿主库目录(ELF 已实现,mcpp#696)。 | | v1.3 | 2026-10-26 | 随 mcpp 2026.10.26.1:§2.2 增加 `requires` 的对象形式与 `visibility`、`[target.<选择器>] requires`;新增 §7 一个进程一套 C++ 运行时(U/I/H、孤岛条件、外来运行时按类型识别、依赖图供给的 libstdc++)(mcpp#838)。 | | v1.4 | 2026-11-03 | 随 mcpp 2026.11.3.1:§2.2 选择器引用层的 `requires` 在层解析之后检查(此前未检查即被丢弃)。 | +| v1.5 | 2026-11-09 | 随 mcpp 2026.11.9.1:§3.4 身份随 C 库;新增 §3.6 能力的缺省提供者、§3.7 C 环境的声明(mcpp#911 G3、G7)。 | | v1.2 | 2026-10-23 | 随 mcpp 2026.10.24.1:§1.3 `prebuilt` 放宽为被点名、按内容识别的预制目录;§2.4 改为由测量回答、`provides` 声明或核对,标为已实现;规则一增加基座是一个供给者与 c-abi 独占律;规则二增加基座上的编译器运行库与可测量的下限;规则三增加可叠加层;新增 §6 目标侧执行(组合、运行器契约、物理边界)。 | diff --git a/docs/specs/targets-and-providers.md b/docs/specs/targets-and-providers.md new file mode 100644 index 000000000..8d5afd043 --- /dev/null +++ b/docs/specs/targets-and-providers.md @@ -0,0 +1,122 @@ +# SPEC-016:目标与提供者 + +| 项 | 值 | +|---|---| +| 规范编号 | SPEC-016 | +| 标题 | 目标与提供者:行、提供者、选择、平台定义的行、报告与锁 | +| 状态 | 评审中 v1.0 | +| 版本 | 1.0 | +| 最后修改 | 2026-11-09 | +| 对应实现 | mcpp >= 2026.11.9.1 | +| 相关设计文档 | `.agents/docs/2026-11-09-target-model-design.md` | +| 相关 issue | mcpp#911 | +| 相关规范 | [SPEC-001](package-identity.md)(引用文法)、[SPEC-002](target-side.md)(层与能力)、[SPEC-013](platforms.md)(平台的语句)、[SPEC-014](packaging.md)(打包)、[SPEC-015](target-sets.md)(目标集合) | +| 使用文档 | [21 —— 目标三元组](../zh/21-the-target-triple.md)、[35 —— 平台](../zh/35-platforms.md) | + +用语按 RFC 2119:**必须 / 禁止**为强制,**应当**为建议,**可以**为可选。 + +## 0. 范围 + +本规范规定一次构建的目标如何确定:用户写出的**请求**、构建所用的**身份**、为身份提供工具链与目标侧各层的**提供者**,以及由 +请求得到身份与提供者的唯一规则。它同时规定平台包如何定义新的身份与提供者,以及选择的结果如何报告、记录与锁定。 + +## 1. 术语 + +| 术语 | 定义 | +|---|---| +| 请求 | `--target`、`[build] target`、config 的缺省目标中写出的字符串 | +| 身份 | 一个行的三元组;决定输出目录、缓存键、`cfg` 求值与锁中的记录 | +| 行 | 身份的数据:段、档位、机器描述、对象格式、执行系统等(§2) | +| 提供者 | 一个身份的一份完整供给:工具链、各层、链接、C 环境(§3) | +| 选择 | 由请求得到身份与提供者的规则(§4) | + +## 2. 行 + +### 2.1 字段 + +行的字段:`triple`(`-[-]`)、`tier`(verified、preview、planned)、`standard`、`exact`、`march`、`mabi`、 +`code-model`、`multilib`、`flags`、`link-emulation`、`clang-triple`、`object-format`、`family`、`data-model`、`exe-suffix`、 +`shared-suffix`、`loader`、`execution`、`descriptor-keys`。 + +- 一个系统可能用到两种以上 C 库时,身份**必须**含 env;env 即 C 库。业界原生标识无 env 段的系统,其原生身份为两段 + (`aarch64-macos`),其余 C 库以 env 区分(`aarch64-macos-musl`)。 +- 身份与宿主无关:同一身份在任何宿主上是同一输出目录、缓存键与 `cfg`。 +- `exact = true` 的行只由写出其全部段的请求得到(Android 的行)。 +- `march`、`mabi`、`code-model`、`flags`、`link-emulation` 是行的机器描述,引擎原样使用,**不可**被替换;机器不同的目标以 + 另一行表达(§5)。 + +### 2.2 内置行 + +内置行是 mcpp 源码树中的数据(`modules/toolchain-model/data/builtin.toml`),由该包的构建程序编入引擎;运行时不读取文件, +不联网。行的顺序是数据:同一系统中标准身份在前。 + +## 3. 提供者 + +| 键 | 语义 | +|---|---| +| `name` | 提供者名;内置提供者以 `builtin:` 指名 | +| `serves` | 所服务的身份;逐段匹配的模式,`*` 代表任一段(`*-linux-musl`) | +| `priority` | 整数,越大越先;内置数据给出缺省值 | +| `hosts` | 提供者在其上可得的宿主(`-`,`*` 与 `{arch}` 为模式) | +| `pairs` | 可与之配对的编译器族;声明了编译器族的构建只取与之配对的提供者 | +| `toolchain`、`sysroot`、`linkage`、`layers`、`link` | 同 SPEC-013 §1 | +| `hosted` | C 环境是否 hosted | +| `execution` | 产物运行的系统 | + +- 引擎不含提供者的类别,也不含实现名;所有提供者同一结构,只有数据不同。 +- 内置提供者:glibc 载荷、musl-gcc、MinGW-w64(与 gcc、llvm 配对)、MSVC、Apple SDK、Android NDK、Emscripten SDK、裸机、 + openkal-platform(ok 映像,及 `*-linux-musl`、`*-windows-musl`、`*-macos-musl` 的 hosted 部分)。 +- 与声明的编译器族配对的提供者提供系统:例如 llvm 与 MinGW-w64 配对时,该提供者的载荷是 clang 的 sysroot、C 库与 C++ 运行时 + (libstdc++)。 + +## 4. 选择 + +1. **显式者优先。** `--platform`、`MCPP_TARGET__PLATFORM`、项目与 config 的 `platform`(含 `builtin:`)只保留所指的 + 提供者;`--platform none` 不用平台包实现的提供者;声明的编译器族只保留与之配对的提供者。 +2. **请求是模式。** 写出 env 的请求只匹配该行;只写 `-` 的请求匹配该系统的全部行(`exact` 的行除外)。 +3. **顺序是数据。** 候选(行与服务它的提供者)按 `priority` 从大到小,相同者按行的顺序,再按提供者名排序。 + `[target..priority] = ` 改写所匹配行上的优先级;项目先于 config。 +4. **取第一个可得者。** 提供者服务该行、在本宿主可得(`hosts`)、探测成立(MSVC)、与声明的编译器族配对,即为可得。皆不可得 + 时取第一个候选,由缺少之处拒绝,拒绝写出各候选及其不可得的原因(`host-cannot-serve`)。 +5. **身份随 C 库。** 依赖图提供 C 库(`mcpp:c-abi`)而请求未写 env 时,身份取实际 C 库的行,重新准备一次。写出的 env 是项目的 + 拼写,不被替换;与图不一致时报告(目标报告的 `asks for the … C ABI`),不拒绝。 + +## 5. 平台定义的行与提供者 + +平台包以 `[[platform.define]]` 定义行(§2.1 的字段),以 `[[platform.provider]]` 声明提供者(§3 的键);`[platform]` 与 +`[platform.hosted]` 是两种简写。 + +- 已解析平台定义的 os 与 env 为解析器接受的词;`cfg(os = …)` 按定义的行求值。 +- 与内置行同名的定义被忽略;`mcpp check platform` 报告它为错误。 +- 平台定义的行不取宿主载荷的 C 库:除非平台或项目声明 sysroot,该行没有 C 库。 +- 首次遇到只在索引中的平台时,请求以临时行进入第一次准备,平台取得后重新准备。 + +## 6. C 环境、链接与执行 + +- **hosted。** 声明 `hosted-standard-library` 的 sysroot,或 `hosted = true` 的提供者,使裸机行不加 `-ffreestanding`。 +- **链接驱动。** 平台的 `link.driver = "compiler"` 在每一行生效,包括行本身以 `link-emulation` 直接调用 ld.lld 的行。 +- **执行。** 行的 `execution` 不是本机系统时:构建不以本机加载器检查产物的运行时闭包;`mcpp run` 与 `mcpp test` 在无运行器、 + 无执行主机时报告「已构建,未运行」。运行器包是目标的工具(`[target..tools]`),以 docs/41 的运行协议启动。 + +## 7. 报告、机器输出与锁 + +- 状态行:`Target <请求> → <身份>`;回落时附原因。 +- `mcpp why target [--format json]` 写出请求、身份、提供者、优先级、回落原因与按序的候选;`mcpp why flags []` 写出每个 + 参数及其来源层(行、平台、工具链、包、引擎)。 +- 构建记录与 `plan` 事件的 `configuration.target` 为 `{request, identity, provider, priority, fallback}`;本机构建为 `null`。 +- 构建程序读 `MCPP_TARGET_PROVIDER`(`mcpp::target_provider()`)。 +- `mcpp.lock` 以 `[target.""] identity, provider` 记录每个请求得到的身份;`--locked` 下选择另一身份的构建被拒绝, + 并写出锁中的与本次的身份。 + +## 8. 兼容性 + +- 已有身份字符串、输出目录与 `cfg` 结果不变;`[target.X]`、`descriptor_keys`、`[platform.hosted]` 照旧可读。 +- 行为变化:只写 `-` 的请求在本宿主不可得其标准身份时取下一个可得的身份(无 MSVC 的宿主上 `x86_64-windows` 为 + `x86_64-windows-musl`;非 macOS 宿主上 `aarch64-macos` 为 `aarch64-macos-musl`;x86_64 宿主上 `aarch64-linux` 为 + `aarch64-linux-musl`);依赖图提供 musl 而请求未写 env 时身份为 `-musl`。 + +## 变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| 1.0 | 2026-11-09 | 初版:行、提供者、选择、平台定义的行、C 环境、执行、报告与锁 | diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 35a17aa38..374d6dc9f 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -73,7 +73,7 @@ mylib = { path = "../mylib" } ``` ```toml -# Git dependency — pick exactly one of tag / branch / rev +# Git dependency — tag / branch / rev 至多一个;都不写即远端的缺省分支 [dependencies] mylib = { git = "https://github.com/user/mylib.git", tag = "v1.0.0" } applib = { git = "https://github.com/user/applib.git", branch = "develop" } @@ -94,6 +94,10 @@ widget = { version = "1.0", backend = "glfw_opengl3" } # Sugar for: features=[" 构建记录相同的解析结果时,`mcpp.lock` 保留原内容与修改时间(2026.10.21.1+)。 仅 LF 与 CRLF 行结束形式不同不会触发改写。这不跳过依赖解析,也不改变锁定范围。 +不写 `tag`、`branch`、`rev` 的 git 依赖跟随远端的缺省分支,分支名取远端的通告(`main`、`master`、`trunk` 等; +2026.11.9.1+),此后按 `branch` 处理并以 commit 锁定。命令行上的平台与工具使用相同的字符串形式: +`--platform git+https://example.org/acme/acme-platform.git#tag=v1`。 + **Git 依赖与 `mcpp.lock`**:`tag` 和 `rev` 已经指向历史中的一个固定点,而 `branch` 是会移动的。首次构建把分支解析成一个 commit 并写进 `mcpp.lock`, 此后每次构建都重新构建**那个** commit —— lock 是权威来源而不是缓存提示, diff --git a/docs/zh/20-toolchains.md b/docs/zh/20-toolchains.md index a7dc2e063..c52afa4fc 100644 --- a/docs/zh/20-toolchains.md +++ b/docs/zh/20-toolchains.md @@ -285,6 +285,13 @@ OS: 归档顶着 MSVC 的名字,MSVC 拿不去用。若有脚本按 `*.lib` 收集 `windows-gnu` 构建的产物,现在要改成 `*.a`。 + +### clang 配 MinGW-w64 载荷(2026.11.9.1+) + +MinGW-w64 提供者与 gcc、llvm 配对。声明 llvm 时(`--toolchain llvm@` 或 `[target.x86_64-windows-gnu] toolchain`), +该载荷按 gcc 的方式安装,并成为 clang 的 sysroot:其 C 库、libstdc++(`bits/std.cc` 即 std 模块,`import std` 可用)与 GCC +运行库,由 lld 链接。构建报告 `Pairs clang ... with the gcc@ payload`。 + ## Windows 上产出 Linux ELF(`x86_64-linux-musl`,无需 WSL) 上一节的镜像:一台 Windows 机器直接产出**完全静态的 Linux 二进制**,不需要 diff --git a/docs/zh/21-the-target-triple.md b/docs/zh/21-the-target-triple.md index b8a0f3f1c..816c857a9 100644 --- a/docs/zh/21-the-target-triple.md +++ b/docs/zh/21-the-target-triple.md @@ -134,36 +134,33 @@ target spec 设置 `exe_suffix: ".js"`。 的 emsdk payload,需要先经过[第 20 章](20-toolchains.md)所述的那次索引 刷新,`mcpp run` 才能找到 runner。 -## 省略第三段 +## 省略第三段:请求只写系统(2026.11.9.1+) -`-` 在每个平台上都是一个完整的目标: +写全的目标是**身份**:输出目录、缓存键与 `cfg()` 判断的对象。只写 `-` 的目标是**请求**:它包含该系统的 +全部身份,构建取第一个其**提供者**(服务它的工具链与目标侧)在本机可得的身份(SPEC-016 §4)。 ```bash -mcpp build --target x86_64-linux # = x86_64-linux-gnu -mcpp build --target x86_64-windows # = x86_64-windows-gnu +mcpp build --target x86_64-linux # Linux 上为 x86_64-linux-gnu;无 glibc 载荷处为 -musl +mcpp build --target x86_64-windows # 装有 MSVC 时为 -msvc,否则为 -musl(openkal-platform) +mcpp build --target aarch64-macos # macOS 上为 Apple 身份,否则为 aarch64-macos-musl mcpp build --target riscv64-none # = riscv64-none-elf -mcpp build --target aarch64-linux # = aarch64-linux-gnu -mcpp build --target aarch64-macos # macOS has no segment to decline ``` -省略它不改变任何身份。输出目录、缓存键,以及 `cfg()` 谓词的对象,都取规范 -形式,因此两种拼法共用同一个指纹,第二次构建是缓存命中,而不是又一次 -完整构建。 +候选按提供者的优先级排序(引擎附带的数据:各系统自己的工具链与 C 库在前,openkal-platform 其次,其他约定在后), +再按行的顺序。报告写出所取的身份,第一个候选在本机不可得时附其原因: -不同的是**请求被记录成了什么**。三元组既充当身份 —— 身份必须完整 —— -又充当请求 —— 请求必须能够什么都不说;mcpp 两者都保留:为身份填上那一 -段,同时记住这次填充只是一次填充。 - -### 补全取自词汇表,而不是取自一个固定的词 - -省略环境段的请求按已知目标表补全。受支持的词法默认值优先;否则采用 -唯一受支持的同族行。没有受支持的行时保留词法形式,并在诊断中列出 -已登记的同族行。歧义请求列出受支持的候选。 +```text + Target x86_64-windows → x86_64-windows-musl + msvc is not available on linux-x86_64 +``` -`aarch64-linux` 选择 `aarch64-linux-gnu`。原生 Linux ARM64 载荷采用 -LLVM 23.1.3 与 glibc。`aarch64-linux-musl` 仍是静态 musl 产物的显式 -拼法。显式写出的环境段保持不变。 +`mcpp why target [--target ]` 按序列出候选、优先级与可得性(脚本用 `--format json`)。工程以 +`[target.<选择器>.priority] <提供者> = ` 改变顺序,或以 `platform = "builtin:<名>"` 指名一个内置提供者。写全的身份 +从不被替换:`--target x86_64-linux-gnu` 构建该身份,或被拒绝并写出所缺之物。`mcpp.lock` 记录每个请求得到的身份, +`--locked` 拒绝选到另一身份的构建。 +依赖图提供 C 库(`mcpp:c-abi`)而请求未写 C 库时,身份取该 C 库:依赖 musl 的 `x86_64-linux` 构建为 +`x86_64-linux-musl`。写出而与图矛盾的段被报告并保留。 ### 采用的拼法 diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md index 242b863bf..b72ee5e2d 100644 --- a/docs/zh/22-target-side.md +++ b/docs/zh/22-target-side.md @@ -271,6 +271,24 @@ requires = ["mcpp:c++-abi=libstdc++"] 另一种变体构建进同一个存储目录,否则第一个消费者就会替之后所有消费者决定 变体。 + +### 缺省提供者(2026.11.9.1+) + +需要某能力的包为每个目标陈述在无人提供时所取的提供者(SPEC-002 §3.6): + +```toml +[package] +requires = ["mcpp:kernel-abi=openkal"] + +[defaults."mcpp:kernel-abi=openkal"] +linux = "openkal-linux@^0.25" +macos = "openkal-macos@^0.25" +"*" = "openkal-posix@^0.25" +``` + +依赖图中的提供者——使用者的依赖,或平台为其层加入的包——优先;否则与目标匹配的选择器的缺省作为该包的依赖进入 +依赖图,构建报告之(`Default ... for ... of ...`)。指名目标的选择器先于 `*`。 + ### 表形式与按目标的 `requires`(mcpp 2026.10.26.1+) `requires` 的条目可以是一张表: diff --git a/docs/zh/35-platforms.md b/docs/zh/35-platforms.md index ff5140f99..beac0d090 100644 --- a/docs/zh/35-platforms.md +++ b/docs/zh/35-platforms.md @@ -234,6 +234,32 @@ seal = [["okld", "note", "{}"], ["okld", "place", "{}"], ["okld", "check", "{} `mcpp test` 而不给 `--target` 时,以该包为平台为其每个 `targets` 构建测试,运行本机架构的那些与给出了执行宿主的 那些(2026.10.29.1+),其余报告为已构建、未运行。 +## 定义目标与提供者(2026.11.9.1+) + +平台可以服务内置行未命名的系统。`[[platform.define]]` 陈述行,`[[platform.provider]]` 陈述服务它的提供者: + +```toml +[[platform.define]] +triple = "x86_64-freebsd" +clang-triple = "x86_64-unknown-freebsd14.1" +object-format = "elf" +family = "unix" +execution = "freebsd" # 其程序运行的系统 + +[[platform.provider]] +serves = ["x86_64-freebsd"] # 模式:`*` 代表任一段 +priority = 100 +hosted = true # C 环境有 `main` +``` + +选中该平台(`[target.x86_64-freebsd] platform = {...}` 或 `--platform`)后,`--target x86_64-freebsd` 构建到 +`target/x86_64-freebsd/`,`cfg(os = "freebsd")` 成立;程序的系统不是本机时,在运行器陈述运行方式之前为「已构建、 +未运行」(docs/41)。行的机器描述属于平台:机器与内置行不同的目标是另一行。`[platform]` 与 `[platform.hosted]` 仍是 +提供者的两种简写。 + +`mcpp new <名> --template platform` 生成一个定义 `-<名>` 的平台包;`mcpp check platform [dir]` 按引擎的读法 +检查它——行、提供者以及所服务的身份与优先级——并拒绝重新定义内置行的行。 + ## 查看一个配置 ```sh diff --git a/docs/zh/90-build-from-source.md b/docs/zh/90-build-from-source.md index b5f27ef50..e01ce28da 100644 --- a/docs/zh/90-build-from-source.md +++ b/docs/zh/90-build-from-source.md @@ -83,7 +83,7 @@ src/ └── scaffold/ `mcpp new` and templates tests/ -├── unit/ 166 mtest programs, discovered by `mcpp test` +├── unit/ 166 个 mtest 用例文件,组成一个测试程序(`[test] programs = "directory"`) └── e2e/ 711 shell scripts against a real binary ``` @@ -95,9 +95,9 @@ tests/ 发现的 C++ 程序。它们检验一个模块的契约,不需要二进制、也不需要文件系统状态。每个文件由 [mtest](https://github.com/mcpp-community/mtest) 的用例组成:一个对象 `UTest name{"Suite/Case", [] { ... }};` 即一个用例,文件不写 `main`(`mcpp test --guide`)。`mcpp test --case 'TargetSelect/*'` 运行模式所指的用例; -`mcpp test -p <成员>` 运行一个成员的用例。 +`mcpp test -p <成员>` 运行一个成员的用例。这些文件组成一个程序,只链接一次,按文件各起一个进程运行。 -**端到端测试**(`tests/e2e/NN_.sh`,711 个文件)拿真实的 `mcpp` +**端到端测试**(`tests/e2e/NN_.sh`,721 个文件)拿真实的 `mcpp` 二进制跑真实工程。`run_all.sh` 是 CI 的入口。`mcpp test` **不**运行它们。 ```bash diff --git a/docs/zh/README.md b/docs/zh/README.md index 6f41ba2e8..9246be87c 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -173,3 +173,4 @@ - [SPEC-013 —— 平台:一类目标的配置,其选择、合并、对依赖图的补全、链接、封装、运行器与载体](../specs/platforms.md) - [SPEC-014 —— 打包与分发:链接、闭包与容器,构建腿,暂存树,平台的树、运行覆盖与单文件包](../specs/packaging.md) - [SPEC-015 —— 目标集合与腿:写法与展开,命令对多条腿的语义,测试归档](../specs/target-sets.md) + - [SPEC-016 —— 目标与提供者:行、提供者、选择规则、平台定义的行、报告与锁](../specs/targets-and-providers.md) diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index fdbd606fd..84cdc830d 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -3948,7 +3948,7 @@ std::expected parse_document(const t::Document& documen for (auto const& [sel, v] : body.as_table()) { auto r = rd.ref(v, std::format("[defaults.\"{}\"].{}", cap, toml_header_key(sel)), false, false); if (!r) return std::unexpected(error(origin, r.error(), v.position)); - m.capabilityDefaults.push_back({ cap, sel, std::move(*r), v.position.line }); + m.capabilityDefaults.push_back({ cap, sel, std::move(*r), static_cast(v.position.line) }); } } } diff --git a/src/build/plan.cppm b/src/build/plan.cppm index cd65778c3..c320b4450 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -2045,9 +2045,13 @@ make_plan(const mcpp::manifest::Manifest& manifest, // the sections nothing references; static constructors are its roots, so // a case that registers itself stays. `[test] link = "objects"` keeps // every object whole. ELF and Mach-O through the compiler driver; MSVC's - // link.exe, a freestanding image and wasm keep their own link lines. + // link.exe, a freestanding image, a platform's ld.lld and wasm keep their + // own link lines. auto keep_what_tests_reach = [&](LinkUnit& lu, const mcpp::manifest::Manifest& owner) { if (owner.testLink == "objects" || msvcTarget || targetTriple.is_freestanding()) return; + // A platform that links with ld.lld itself states its own link line + // (an ok image collects its sections there already). + if (platform && platform->active && platform->fields.link.driver.value == "lld") return; if (!targetTriple.empty() && targetTriple.str().starts_with("wasm")) return; const bool macho = targetTriple.empty() ? bool(mcpplibs::os::is_macos) : targetTriple.is_mach_o(); lu.linkFlags.push_back(macho ? "-Wl,-dead_strip" : "-Wl,--gc-sections"); diff --git a/src/build/prepare/platform.cpp b/src/build/prepare/platform.cpp index e0a255f41..5c9de3438 100644 --- a/src/build/prepare/platform.cpp +++ b/src/build/prepare/platform.cpp @@ -968,9 +968,9 @@ std::vector split_words(std::string_view v) { // THE PLATFORM'S NAMES OF ITS ARTIFACTS STAND (mcpp#911 B2): what follows // `{name}` in `link.exe` and `link.shared` is the platform's format (`.oke`, -// `.okso`), which its own steps read. A later statement that would change it -// (a build program's `artifact-name`, written for the host's suffix) is -// reported, and the platform's pattern is kept. +// `.okso`), which its own steps read. A build program's `artifact-name` that +// would change it -- written for the host's suffix, without knowing the +// platform -- is reported, and the platform's pattern is kept. namespace { void keep_platform_naming(mp::Statement& s, const mp::Statement& platform, std::string_view display, std::vector* conflicts) { @@ -981,7 +981,9 @@ void keep_platform_naming(mp::Statement& s, const mp::Statement& platform, std:: for (auto [field, base, kind] : { std::tuple{ &s.link.exe, &platform.link.exe, "exe" }, std::tuple{ &s.link.shared, &platform.link.shared, "shared" } }) { if (!base->set || base->origin.tier != mp::Tier::Platform || suffix(base->value).empty()) continue; - if (!field->set || field->origin.tier == mp::Tier::Platform || suffix(field->value) == suffix(base->value)) + // A build program's `artifact-name` only: the project and the machine + // state a name on purpose, and theirs stands as any field's does. + if (!field->set || field->origin.tier != mp::Tier::Program || suffix(field->value) == suffix(base->value)) continue; if (conflicts) conflicts->push_back(std::format( diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp index 9e5389acd..70b6c024a 100644 --- a/src/build/prepare/target_side.cpp +++ b/src/build/prepare/target_side.cpp @@ -1070,14 +1070,12 @@ static std::expected cabi_identity(PrepareState& state) { const auto row = std::format("{}-{}-{}", id->arch, id->os, env); if (!mcpp::toolchain::target::find_row(row)) return {}; - if (!state.requestedCAbi.empty() && !state.overrides.target_request) { - refusal::record(refusal::Code::TargetCabiConflict); - return std::unexpected(std::format( - "target '{}' asks for the `{}` C library, and the build links `{}` ({}).\n" - " An identity names the C library it links, so this build is `{}`.\n" - " build it as `--target {}`, or as `--target {}-{}` to let the C library name it.", - id->str(), state.requestedCAbi, ts.cAbi.interfaceName, ts.cAbi.impl, row, row, id->arch, id->os)); - } + // A WRITTEN C LIBRARY IS THE PROJECT'S SPELLING AND STAYS: the mismatch is + // reported (the target report's `asks for the ... C ABI`), not refused -- + // refusing broke every configuration spelling the host's identity over a + // graph-supplied C library (e2e 282). Only a request that names none + // follows the C library. + if (!state.requestedCAbi.empty()) return {}; if (state.overrides.target_request) return {}; // the second pass: the row it was given g_targetRestart = row; return std::unexpected(std::string("mcpp:target-restart")); diff --git a/src/build/refusal.cppm b/src/build/refusal.cppm index 6167dfd22..6c9bcb07d 100644 --- a/src/build/refusal.cppm +++ b/src/build/refusal.cppm @@ -203,13 +203,6 @@ enum class Code { // A platform's build program stated a directive outside its phase's // vocabulary (build, link, runner, runners). PlatformPhaseDirective, - // SPEC-016: no provider of the request's candidates serves it here. - TargetUnserved, - // SPEC-016 §5: the request names a C library the build does not link. - TargetCabiConflict, - // SPEC-016 §2: a platform defines a row another definition already has - // with other facts. - TargetRowConflict, Other, // a refusal that has not been given a code yet }; @@ -271,9 +264,6 @@ constexpr std::string_view name(Code c) { case Code::PlatformVersionGroup: return "platform-version-group"; case Code::PlatformPhaseDirective: return "platform-phase-directive"; - case Code::TargetUnserved: return "target-unserved"; - case Code::TargetCabiConflict: return "target-cabi-conflict"; - case Code::TargetRowConflict: return "target-row-conflict"; case Code::Other: return "other"; } return "other"; diff --git a/tests/e2e/1044_clang_pairs_with_the_mingw_payload.sh b/tests/e2e/1044_clang_pairs_with_the_mingw_payload.sh index fba2bfb11..e366d9291 100755 --- a/tests/e2e/1044_clang_pairs_with_the_mingw_payload.sh +++ b/tests/e2e/1044_clang_pairs_with_the_mingw_payload.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# requires: llvm mingw-cross wine +# requires: mingw-cross wine source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" # 1044 -- clang pairs with the MinGW-w64 payload (SPEC-016 §6). # @@ -9,7 +9,9 @@ source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" # # Criteria: the build reports the pairing; the compile line carries the payload as `--sysroot` # and `-stdlib=libstdc++`; the program, which uses `import std` and `std::println`, runs under -# wine; its imports are the system's DLLs only. +# wine; its imports are the system's DLLs only. The llvm payload is installed by the build itself +# when this machine has none (the `--toolchain` request), so the test needs only the MinGW-w64 cross +# payload and wine. set -e t=$(mktemp -d); trap 'rm -rf "$t"' EXIT fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } diff --git a/tests/e2e/299_a_request_that_named_no_c_library_resolves_to_a_row_that_exists.sh b/tests/e2e/299_a_request_that_named_no_c_library_resolves_to_a_row_that_exists.sh index a080f6cfd..92ea117f0 100755 --- a/tests/e2e/299_a_request_that_named_no_c_library_resolves_to_a_row_that_exists.sh +++ b/tests/e2e/299_a_request_that_named_no_c_library_resolves_to_a_row_that_exists.sh @@ -94,9 +94,23 @@ esac # AND THE ROW IS THE IDENTITY, NOT THE SPELLING. The output directory is the # identity. Asserting only "it did not refuse with tier-planned" would stay # green in a world where the completion picked some other row entirely. -case "$row" in - aarch64-linux-gnu|aarch64-unknown-linux-gnu) echo " ok and it resolved to the native GNU row ($row)" ;; - *) echo "FAIL: aarch64-linux resolved to '$row', not the GNU row"; exit 1 ;; +# SINCE 2026.11.9.1 THE REQUEST IS A PATTERN (SPEC-016 §4): it takes the GNU row +# where that row's provider is available -- an aarch64 Linux host -- and the +# next available row of the system elsewhere (`aarch64-linux-musl`, through the +# musl cross payload or openkal-platform), the report saying why. +hostpair="$(uname -s)-$(uname -m)" +case "$hostpair" in + Linux-aarch64) + case "$row" in + aarch64-linux-gnu|aarch64-unknown-linux-gnu) echo " ok and it resolved to the native GNU row ($row)" ;; + *) echo "FAIL: aarch64-linux resolved to '$row' on an aarch64 Linux host, not the GNU row"; exit 1 ;; + esac ;; + *) + case "$row" in + aarch64-linux-gnu|aarch64-unknown-linux-gnu|aarch64-linux-musl|aarch64-unknown-linux-musl) + echo " ok and it resolved to a row of the system available here ($row)" ;; + *) echo "FAIL: aarch64-linux resolved to '$row', no aarch64 Linux row"; exit 1 ;; + esac ;; esac case "$row" in aarch64*) ;; @@ -111,21 +125,20 @@ esac # "send every bare -linux to musl", which passes half one perfectly. hostr="$(reason_for x86_64-linux)" hostrow="$(resolved_row x86_64-linux)" -case "$hostrow" in - *-gnu) echo " ok and x86_64-linux is still gnu ($hostrow, reason '$hostr')" ;; - *) echo "FAIL: x86_64-linux resolved to '$hostrow' (reason '$hostr')"; exit 1 ;; +case "$hostpair:$hostrow" in + Linux-x86_64:*-gnu) echo " ok and x86_64-linux is still gnu on an x86_64 Linux host ($hostrow, reason '$hostr')" ;; + Linux-x86_64:*) echo "FAIL: x86_64-linux resolved to '$hostrow' on an x86_64 Linux host (reason '$hostr')"; exit 1 ;; + *:x86_64*linux*) echo " ok and x86_64-linux is a row of the system available here ($hostrow)" ;; + *) echo "FAIL: x86_64-linux resolved to '$hostrow' (reason '$hostr')"; exit 1 ;; esac # ── Half three: a written segment is a request, not a gap ──────────────── # -# An explicit segment names the same GNU identity. Host serviceability is a -# separate property and agrees with the omitted-segment query. -wr="$(reason_for aarch64-linux-gnu)" -if [ "$wr" = "$r" ] && [ "$(resolved_row aarch64-linux-gnu)" = "$row" ]; then - echo " ok and writing -gnu preserves the selected GNU row" -else - echo "FAIL: written and omitted GNU segments disagree ($wr / $r)" - exit 1 -fi +# An explicit segment names the identity whatever the host: it is never replaced +# by another row, served here or not. +case "$(resolved_row aarch64-linux-gnu)" in + aarch64-linux-gnu|aarch64-unknown-linux-gnu) echo " ok and writing -gnu keeps the GNU row" ;; + *) echo "FAIL: written -gnu resolved to '$(resolved_row aarch64-linux-gnu)'"; exit 1 ;; +esac echo "OK: a request that named no C library resolves to a row that exists" diff --git a/tests/e2e/746_macos_deployment_target_is_target_keyed_not_host_keyed.sh b/tests/e2e/746_macos_deployment_target_is_target_keyed_not_host_keyed.sh index 8cce94252..ff226d854 100755 --- a/tests/e2e/746_macos_deployment_target_is_target_keyed_not_host_keyed.sh +++ b/tests/e2e/746_macos_deployment_target_is_target_keyed_not_host_keyed.sh @@ -72,6 +72,9 @@ macos_deployment_target = "$1" [target.aarch64-macos] toolchain = "llvm@$LLVM_VERSION" +# The Apple provider by name: on a host without it, a request of the system +# would otherwise take the next available row, aarch64-macos-musl (SPEC-016 §4). +platform = "builtin:apple" EOF } From 21537b6cc444cad319bc130929b1caf15e5d1531 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 11:55:12 +0900 Subject: [PATCH 26/45] mcpp why platform and reading-time checks replace mcpp check; a patch is written with git diff; a test run is kept in the mtest store - `mcpp check` and `mcpp patch` are removed. A platform's statement is checked as its manifest is read (a built-in row redefined or a row defined twice is an error; a provider serving no row and a priority tie are warnings), and `mcpp why platform` lists the rows it defines and the identities it serves. One list of a statement's providers (mcpp.manifest.platform_providers) serves the registration, the check and the report. - `mcpp why deps` and resolution.json state each package's root (`manifest_dir`), from which `git diff` writes a patch (docs/05). - `mcpp test` keeps each run in the mtest store's form under target/mtest (`[test] store`); the schedule of each process is recorded. - A test that keeps what it reaches reads the protocol marker from the objects it was linked from when the link dropped it (mtest before 0.5.1). - A platform that links with ld.lld keeps its own link line for tests. - The tier check reads data/builtin.toml; docs/21 lists the three rows it lacked. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../docs/2026-11-09-target-model-design.md | 9 +- .github/tools/check_target_tiers.py | 14 +- CHANGELOG.md | 22 ++- docs/04-mcpp-toml.md | 3 + docs/05-dependencies.md | 17 +- docs/08-testing.md | 12 +- docs/09-commands-by-scenario.md | 16 +- docs/21-the-target-triple.md | 3 + docs/35-platforms.md | 11 +- docs/50-machine-output.md | 16 ++ docs/specs/targets-and-providers.md | 6 +- docs/zh/04-mcpp-toml.md | 3 + docs/zh/05-dependencies.md | 12 +- docs/zh/08-testing.md | 9 +- docs/zh/09-commands-by-scenario.md | 10 +- docs/zh/21-the-target-triple.md | 3 + docs/zh/35-platforms.md | 6 +- docs/zh/50-machine-output.md | 16 ++ modules/manifest/src/manifest.cppm | 1 + modules/manifest/src/platform_providers.cppm | 142 +++++++++++++++++ modules/manifest/src/toml.cppm | 32 ++-- modules/manifest/src/types.cppm | 4 + src/build/execute.cppm | 126 ++++++++++++++- src/build/flag_origins.cpp | 2 +- src/build/prepare.cppm | 3 - src/build/prepare/plan.cpp | 5 - src/build/prepare/platform.cpp | 20 +-- src/build/prepare/target_side.cpp | 5 + src/cli.cppm | 32 +--- src/cli/cmd_check.cpp | 121 -------------- src/cli/cmd_check.cppm | 18 --- src/cli/cmd_patch.cpp | 147 ------------------ src/cli/cmd_patch.cppm | 20 --- src/cli/cmd_self.cppm | 2 +- src/doctor.cppm | 62 +++++++- src/pm/patch.cppm | 134 ---------------- src/scaffold/create.cppm | 4 +- ...irectory_and_the_tests_a_change_affects.sh | 8 +- ..._edited_and_its_change_saved_as_a_patch.sh | 53 ------- ...t_diff_from_the_package_root_is_applied.sh | 49 ++++++ ...a_platform_skeleton_is_made_and_checked.sh | 58 ------- ...s_statement_read_as_the_engine_reads_it.sh | 70 +++++++++ ...0_a_test_run_is_kept_in_the_mtest_store.sh | 50 ++++++ tests/unit/test_patch.cpp | 33 ---- tests/unit/test_platform.cpp | 62 ++++++++ 45 files changed, 746 insertions(+), 705 deletions(-) create mode 100644 modules/manifest/src/platform_providers.cppm delete mode 100644 src/cli/cmd_check.cpp delete mode 100644 src/cli/cmd_check.cppm delete mode 100644 src/cli/cmd_patch.cpp delete mode 100644 src/cli/cmd_patch.cppm delete mode 100755 tests/e2e/1046_a_dependency_is_edited_and_its_change_saved_as_a_patch.sh create mode 100755 tests/e2e/1046_a_patch_made_with_git_diff_from_the_package_root_is_applied.sh delete mode 100755 tests/e2e/1049_a_platform_skeleton_is_made_and_checked.sh create mode 100755 tests/e2e/1049_a_platform_skeleton_is_made_and_its_statement_read_as_the_engine_reads_it.sh create mode 100755 tests/e2e/1050_a_test_run_is_kept_in_the_mtest_store.sh diff --git a/.agents/docs/2026-11-09-target-model-design.md b/.agents/docs/2026-11-09-target-model-design.md index 049a8f2f8..63920af62 100644 --- a/.agents/docs/2026-11-09-target-model-design.md +++ b/.agents/docs/2026-11-09-target-model-design.md @@ -118,7 +118,7 @@ Emscripten SDK。openkal-platform 的 hosted 部分为提供者,服务 `*-linu |---|---| | G6 描述符 | 源码制品一份、与目标无关;预编译 payload 按身份的轴匹配,含 `any` 与回落;`linux`、`macosx`、`windows`、`openkal`、`none` 块作为轴组合的别名读取,已有索引条目不变;`descriptor_keys` 照旧可读,不再必需 | | G7 能力槽 | SPEC-002 的层机制推广到普通包:`requires` 加按系统的默认提供者;解析顺序为依赖图 > 平台 > 默认值。openkal-run 的清单随之改写 | -| G8 补丁根 | 包根(解析后的清单所在目录)或显式 `patch_root`;`mcpp patch edit/save` | +| G8 补丁根 | 包根(解析后的清单所在目录)或显式 `patch_root`;`mcpp why deps` 报告包根,补丁由 `git diff` 写出 | | G9 按产物的编译变体 | `[targets.X.build] defines/cflags/cxxflags` 作用于该产物链接的本包翻译单元,在 `obj/_variant//` 编译变体;`[targets.X] defines/flags` 维持只作用于入口;模块接口每包一个,不做变体 | | G10 载体与打包 | 任何已解析平台能构建的行,按平台声明作载体或打包的系统 | | G11 执行宿主 | 运行器协议:传输、运行、输出流、退出码,由 runner 包实现(VM、ssh、设备);无运行器的异系统产物为「已构建,未运行」 | @@ -200,7 +200,7 @@ Emscripten SDK。openkal-platform 的 hosted 部分为提供者,服务 `*-linu | 应用开发者 | 不必学新概念 | 「行」「提供者」「优先级」是实现与平台作者的概念 | R3:用户文档只用「请求的 target」「构建的 target」「提供者」三个词;「行」「priority」只出现在 SPEC-016 与平台作者文档 | | 应用开发者、平台开发者 | 指定用谁 | 「选一个提供者」现在有 `platform`、`toolchain`、`--platform none` 三种写法,语义交叠 | R4:内置提供者有名字(如 `builtin:msvc`、`builtin:mingw`、`builtin:glibc`);`platform` 选择一个提供者,名字或包引用皆可;`--platform none` 表示不用平台包、只用内置提供者,含义不变;`toolchain` 只选编译器族与版本,与提供者按 `pairs` 配对。不新增键或标志 | | 库开发者 | 条件依赖与预编译 payload 按 target 匹配 | Q5 使依赖图供给 C 库的项目身份改变,以 `cfg(env = "gnu")` 写的条件可能不再成立 | R5:CHANGELOG 与 docs/22 写出迁移:按 C 库写的条件应以实际 C 库为准;`mcpp why target` 显示 `cfg` 求值所用的身份 | -| 平台开发者 | 用最少的键写出一个平台 | `[platform]` 与 `[platform.hosted]` 是两个特例的提供者,第三个提供者没有写法 | R6:一般形式 `[[platform.provider]]`(`serves`、`priority`、`toolchain`、`layers`、`link`、`hosted`、`execution`、`pairs`);`[platform]` 与 `[platform.hosted]` 为其两种简写,照旧可读。`mcpp new --template platform` 生成一个最小平台;`mcpp check platform` 校验 schema、行与提供者的一致、`priority` 冲突,并列出它服务的身份 | +| 平台开发者 | 用最少的键写出一个平台 | `[platform]` 与 `[platform.hosted]` 是两个特例的提供者,第三个提供者没有写法 | R6:一般形式 `[[platform.provider]]`(`serves`、`priority`、`toolchain`、`layers`、`link`、`hosted`、`execution`、`pairs`);`[platform]` 与 `[platform.hosted]` 为其两种简写,照旧可读。`mcpp new --template platform` 生成一个最小平台;读取平台清单时校验行与提供者的一致与 `priority` 冲突;`mcpp why platform` 列出它服务的身份 | | 构建插件开发者 | 构建程序读到的 target 与引擎一致 | `mcpp::target_os()` 等访问器的文档以封闭集合描述取值;新系统的 os 值须照样流过 | R7:访问器返回身份的轴(字符串,取值由行定义);新增 `mcpp::target_provider()`(`MCPP_TARGET_PROVIDER`);docs/30 的取值表改为「由行定义,内置行为……」 | | agent | 可脚本化、确定、可解释 | 构建的机器输出只有身份,没有请求与提供者;拒绝没有稳定的代码 | R8:机器输出的 target 记录为 `{request, identity, provider, priority, fallback}`;选择的拒绝有稳定代码(`target-unserved`、`target-ambiguous` 等,进 SPEC-016 的拒绝码表);文档建议自动化写完整身份 | | mcpp 开发者 | 迁移不引入回归,代码不再散落 | 原生约定由代码移为数据是一次大改 | 第 9 节的等价性快照为合入条件;选择逻辑集中于一个模块,旧的代理条件(目标表 pin、`toolchain_decision` 的拒绝、三元组补全中的推断)随迁移删除,不保留双轨 | @@ -370,7 +370,7 @@ mcpp 的单元测试(`tests/unit/` 166 个文件,以及各成员的 `modules | A1 | mcpp | 行与内置提供者为数据(build.mcpp 编入静态表)、等价性快照 | — (已完成) | | A2 | mcpp | 选择:行的登记、开放的 os 与 env、`select`、prepare 接入(pin、平台、可服务判断、报告) | A1(进行中) | | A3 | mcpp | 身份取实际 C 库(重新准备)、显式身份与图冲突的拒绝 | A2 | -| A4 | mcpp | `[[platform.define]]`、`[[platform.provider]]` 的读取与登记;`[platform]`、`[platform.hosted]` 为简写;`mcpp check platform`、`mcpp new --template platform` | A2 | +| A4 | mcpp | `[[platform.define]]`、`[[platform.provider]]` 的读取与登记;`[platform]`、`[platform.hosted]` 为简写;清单读取时的语句校验、`mcpp why platform`、`mcpp new --template platform` | A2 | | A5 | mcpp | llvm 配 mingw 载荷 | A2 | | A6 | mcpp | #911 G2–G11、B1–B3 | A4 | | A7 | mcpp | 统一的包引用(`git+`、缺省分支、`builtin:`);SPEC-001 修订 | — | @@ -399,5 +399,6 @@ mcpp 的单元测试(`tests/unit/` 166 个文件,以及各成员的 `modules | 拒绝码 | 沿用 `host-cannot-serve`、`unknown-target`、`tier-planned` | 不新增与既有码同义的 `target-unserved` 等 | | B2 | 只约束构建程序的 `artifact-name` | 项目与本机对产物名的陈述是显式选择,照常生效 | | G9 | 新表 `[targets.X.build]` | 现有键的作用范围不变,升级无感 | -| G8 `mcpp patch edit/save` | 保留 | 补丁是核心功能,编辑流程与之同属(同 pnpm 的 `patch`/`patch-commit`) | +| G8 `mcpp patch edit/save` | 不采纳 | 核心保留的是补丁契约(`patches`、包根、`patch_root`);编辑与生成是通用工具的事:`mcpp why deps` 报告包根,`git diff` 写出补丁,无功能缺口 | +| `mcpp check platform` | 不采纳,并入清单读取与 `mcpp why platform` | 一级子命令须有独立的场景;平台语句的校验在读取清单时进行,作者与使用者在构建中即得到;所服务的身份属来源解释,与 `why target`、`why flags` 同族 | | 测试程序 | 按目录组成为可选(`[test] programs`);`[test] split` 按文件分进程为缺省;测试只保留可达部分(`--gc-sections`) | 缺省不改变既有工程的程序划分与命名;按文件分进程同时保留隔离与并行 | diff --git a/.github/tools/check_target_tiers.py b/.github/tools/check_target_tiers.py index d156cf9c5..07e8a1373 100755 --- a/.github/tools/check_target_tiers.py +++ b/.github/tools/check_target_tiers.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Every documented tier agrees with the target table. -WHY THIS EXISTS. `kKnownTargets` in modules/toolchain-model/src/triple.cppm is -the single source for a row's tier, and four documents restate it: both +WHY THIS EXISTS. The built-in rows, modules/toolchain-model/data/builtin.toml +(SPEC-016 §2.2), are the single source for a row's tier, and four documents restate it: both READMEs, both copies of docs/21. When `wasm32-emscripten` became `verified` and the Android rows gained tiers, docs/21 was updated and the READMEs were not -- so the front page told a reader that three targets were `planned` while the @@ -16,17 +16,19 @@ """ import re import sys +import tomllib from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -TABLE = ROOT / "modules/toolchain-model/src/triple.cppm" +TABLE = ROOT / "modules/toolchain-model/data/builtin.toml" TIERS = ("verified", "preview", "planned") # The engine's answer. rows = {} -for m in re.finditer(r'^\s*\{\s*"([a-z0-9_.+-]+)",\s*"(verified|preview|planned)"', - TABLE.read_text(), re.M): - rows[m.group(1)] = m.group(2) +with TABLE.open("rb") as f: + for row in tomllib.load(f).get("platform", {}).get("define", []): + if row.get("tier") in TIERS: + rows[row["triple"]] = row["tier"] if len(rows) < 20: sys.exit(f"ERROR: only {len(rows)} rows parsed from {TABLE.name}; " "the pattern no longer matches the table") diff --git a/CHANGELOG.md b/CHANGELOG.md index 112a16094..79c71610e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,9 +39,9 @@ provider of a capability it requires, taken when neither the graph nor the platform provides it (G7). - A patch names files from the package's root; `patch_root` names another - directory. `mcpp patch edit ` and `mcpp patch save ` - edit a dependency's sources and save the change as the edge's next patch - (G8). + directory, and a patch written from the archive's top applies as before. + `mcpp why deps` reports each dependency's root, from which `git diff` writes + a patch (G8). - `[targets..build]` compiles the shared sources an artifact links with its own defines and flags, as the artifact's variant (G9). - A row a platform defines is packed and carries as a built-in row does; @@ -53,11 +53,18 @@ its cases (`[test] split = "file" | "case" | "none"`), and a test keeps what it reaches: units are compiled with one section per function and per object and tests link with `--gc-sections` (`[test] link = "objects"` keeps every - object whole). mcpp's own unit tests are one program per directory: 166 + object whole); a protocol marker nothing references is read from the objects + the test was linked from. A run is kept in the mtest store's form under + `target/mtest` (`[test] store`), which `mtest report`, `analyze` and `graph` + read. mcpp's own unit tests are one program per directory: 166 programs of 32 MB became one of 37 MB, run as 166 processes. - clang pairs with the MinGW-w64 payload for `x86_64-windows-gnu`: the payload is clang's sysroot and libstdc++ its C++ library, `import std` included. -- `mcpp new --template platform` and `mcpp check platform`. +- `mcpp new --template platform`. Reading a platform's manifest refuses a + definition of a built-in row and a row defined twice, and warns of a provider + that serves no row and of two providers at one priority for one identity; + `mcpp why platform` lists the rows a platform defines and the identities it + serves, with their providers and priorities. - `mcpp pack --all-programs` ships every program of a package. - A git reference that names no tag, rev or branch follows the remote's default branch; `--platform git+[#tag=|rev=|branch=]`. @@ -71,8 +78,9 @@ identities; `aarch64-linux` on an x86_64 host is `aarch64-linux-musl`. An identity written whole is not replaced. - The identity follows the C library: a project whose graph supplies musl - builds `x86_64-linux` as `x86_64-linux-musl`; an explicit identity that - disagrees with the graph's C library is refused (`target-cabi-conflict`). + builds `x86_64-linux` as `x86_64-linux-musl`; an explicit identity is the + project's spelling and is kept, and a disagreement with the graph's C library + is reported. - A git dependency names at most one of rev, tag and branch. - `mcpp pack` with several programs packs the one named after the package and names the others, and refuses when none is so named. diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index 2a1d3a65f..a53bc234f 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -2156,6 +2156,9 @@ discover = ["tests/**/*.cpp"] # the default | `main` | `{ from = "/" }` | the entry target every test program begins in; each test file is then an ordinary translation unit of its program and defines no `main` (§2.2, `kind = "entry"`) *(2026.10.26.2+)* | | `start` | symbol | the symbol every test image is entered at, on a freestanding target (§2.2, `start`) *(2026.10.26.2+)* | | `programs` | `"file"` or `"directory"` | `"file"` (the default): every discovered file is a program; `"directory"`: the files of one directory below a glob's fixed directory are one program, named by the directory, beginning at the file that defines `main` when one does *(2026.11.8.1+)* | +| `split` | `"file"`, `"case"` or `"none"` | how a program that speaks the test protocol is run: a process per file of its cases (the default), per case, or once *(2026.11.9.1+)* | +| `link` | `"used"` or `"objects"` | `"used"` (the default): a test links what it reaches (`--gc-sections`, `-dead_strip`); `"objects"`: every object whole *(2026.11.9.1+)* | +| `store` | directory or `"none"` | where a run is kept in the mtest store's form, relative to the project; the default is `target/mtest`; `"none"` keeps nothing *(2026.11.9.1+)* | The globs use the vocabulary of `[build] sources`. A test's name is its path relative to the fixed directory of the first glob that matched it, without the diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index ed9dbf71e..aa65613e0 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -680,10 +680,19 @@ verified source. names another directory, relative to the package's root and inside its source. A patch written from the archive's top, as before 2026.11.9, applies as written, and the build says so. -- `mcpp patch edit ` copies the package's root, as the build reads - it (its patches applied), to `target/patch//`; `mcpp patch save - ` writes the change as the edge's next patch, - `patches/-.patch`, and adds it to the edge's `patches`. +- `mcpp why deps` reports each dependency's root as the build reads it (its + patches applied). A patch is the difference git writes from a copy of that + tree: + + ```sh + cp -R /tmp/zlib && cd /tmp/zlib + git init -q && git add -A && git commit -qm base + # edit, then + git diff > /patches/0001-fix.patch + ``` + + and the edge lists it in `patches`. A further patch is written the same way + from the root the patched build reports, and is listed after the first. - Version and git dependencies may carry patches; a `path` dependency is a tree its consumer owns and is refused with them. - `mcpp.lock` names the variant and each patch by its path and SHA-256; diff --git a/docs/08-testing.md b/docs/08-testing.md index 28f96c5c5..d2e1fbd31 100644 --- a/docs/08-testing.md +++ b/docs/08-testing.md @@ -79,6 +79,14 @@ every function (mcpp's own unit tests: 32 MB each to 14 MB). Static constructors are the collection's roots. `[test] link = "objects"` keeps every object whole. +A run is kept in the form of the mtest store (2026.11.9.1+): `target/mtest/` +holds `runs//run.json`, a line per process in `programs.ndjson` (its +status, duration, start, worker and selected cases) and a line per case in +`cases.ndjson`, and `index.ndjson` lists the runs. `mtest report`, `analyze` +and `graph --store target/mtest` read them as mtest's own runs: the report, the +slowest cases and the run's schedule. `[test] store = ""` names another +directory, relative to the project; `"none"` keeps nothing. + A suite compiled from several sources of its own is also a package: a workspace member whose `[build] sources` carries the suite and whose one test program drives it, selected with `mcpp test -p `. @@ -321,7 +329,9 @@ test-protocol = 1 ``` or when its file carries the marker `mtest-protocol=1`, as a program built -with mtest's libraries does. A program that carries the marker and writes no +with mtest's libraries does; of a test that keeps what it reaches, an object it +was linked from carrying the marker counts as well, so a marker nothing +references (mtest before 0.5.1) is not lost at the link. A program that carries the marker and writes no `program` event spoke no protocol: a file of plain `main` linked beside a framework's library is one case, judged by its exit status, run and listed. mcpp then gives the program the protocol's variables (`MTEST_PROTOCOL`, `MTEST_MODE`, `MTEST_REPORT`, `MTEST_TMPDIR`, and diff --git a/docs/09-commands-by-scenario.md b/docs/09-commands-by-scenario.md index 91e375f63..be8d8d42d 100644 --- a/docs/09-commands-by-scenario.md +++ b/docs/09-commands-by-scenario.md @@ -216,9 +216,11 @@ dependency graph: requested by huxdemo.comp@0.1.0 as 'fw' in [dependencies] linked static (default) instances: target + root: /work/fw ``` -`instances` states whether the package is compiled for the target, for the +`root` is the package's root as the build reads it, a patched dependency's +being its variant's (2026.11.9.1+; docs/05). `instances` states whether the package is compiled for the target, for the build programs, or for both (2026.10.11.1+). `mcpp why build-programs` lists the build programs' graphs: each program, the libraries it uses, the identity it was compiled from and its binary. `mcpp why compat` lists every use of a @@ -227,8 +229,14 @@ deprecated reading with the release that removes it (51 — Supported Versions). The same graph is recorded under `graph` in `target///resolution.json`, one entry per package with the root first: `package` (canonical identity, namespace, name, version, source), -`root`, `requested_by` (`requester`, `key`, `table`), and, for a library, -`link` (`form`, `reason`). +`root`, `requested_by` (`requester`, `key`, `table`), `manifest_dir` (the +package's root), and, for a library, `link` (`form`, `reason`). + +`mcpp why target` lists a request's candidates in order (docs/21), `mcpp why +flags []` each word of a unit's command line with the layer that gave it, +and `mcpp why platform` the rows a platform defines and the identities it +serves (docs/35); the last two are not part of the report without a topic +(2026.11.9.1+). The topic is `toolchain`, `runtime`, `deps` or `runners`, and all four report when none is named. `--target` and `--toolchain` turn the report into a query @@ -765,7 +773,7 @@ mcpp is involved and its documentation will not help. ## Current limitations - `mcpp why --format json` is defined for the `toolchain`, `sources`, `tool`, - `payload`, `build-programs` and `compat` topics. The other topics report + `payload`, `build-programs`, `compat`, `target`, `flags` and `platform` topics. The other topics report `'' has no machine-readable shape yet` and exit non-zero. - `mcpp search` matches a substring; there is no field selector, and no way to restrict a search to one namespace. diff --git a/docs/21-the-target-triple.md b/docs/21-the-target-triple.md index e6e84b596..97eb0e197 100644 --- a/docs/21-the-target-triple.md +++ b/docs/21-the-target-triple.md @@ -528,10 +528,13 @@ other's rows. | `x86_64-windows-msvc` | verified | — | — | — | — | system | | `aarch64-macos` | verified | — | — | — | SDK | — | | `x86_64-macos` | planned | — | planned | planned | planned | planned | +| `aarch64-macos-musl` | preview | `llvm@23.1.3` | graph | graph | graph | graph | +| `x86_64-macos-musl` | preview | `llvm@23.1.3` | graph | graph | graph | graph | | `riscv64-none-elf` | verified | `llvm@23.1.3` | payload | payload | payload | payload | | `riscv32-none-elf` | verified | `llvm@23.1.3` | payload | payload | payload | payload | | `aarch64-none-elf` | preview | `llvm@23.1.3` | payload | payload | payload | payload | | `x86_64-none-elf` | preview | `llvm@23.1.3` | payload | payload | payload | payload | +| `i686-none-elf` | preview | `llvm@23.1.3` | payload | payload | payload | payload | | `thumbv6m-none-eabi` | verified | `llvm@23.1.3` | payload | payload | payload | payload | | `thumbv7m-none-eabi` | verified | `llvm@23.1.3` | payload | payload | payload | payload | | `thumbv7em-none-eabi` | preview | `llvm@23.1.3` | payload | payload | payload | payload | diff --git a/docs/35-platforms.md b/docs/35-platforms.md index c61256923..0f3a49d74 100644 --- a/docs/35-platforms.md +++ b/docs/35-platforms.md @@ -312,9 +312,14 @@ built-in row is another row. `[platform]` and `[platform.hosted]` remain the two shorthands of a provider. `mcpp new --template platform` writes a platform package defining -`-`; `mcpp check platform [dir]` reads one as the engine does -- -its rows, its providers and the identities it serves with their priorities -- -and refuses a row that redefines a built-in one. +`-`. Reading a platform's manifest checks its statement against the +rows: a definition of a built-in row, or a row defined twice, is an error +wherever the platform is read; a provider that serves no row, and two providers +that serve one identity at one priority, are warnings of the platform's own +build. `mcpp why platform` lists the rows the platform defines and the +identities it serves, each with its provider and priority: in the platform's +directory its own, in a project the one its target selects (`--target`; +`--format json`). ## Inspecting a configuration diff --git a/docs/50-machine-output.md b/docs/50-machine-output.md index 0dfe54ca2..371a44988 100644 --- a/docs/50-machine-output.md +++ b/docs/50-machine-output.md @@ -513,6 +513,22 @@ carries `status` (`ok` or `refused`) and: | `mcpp.why.build-programs` | `graphs[]` | one entry per build programs' graph: `workspace` (its directory) and `programs[]`, each `{member, package, source, libraries[], identity, binary}` | | `mcpp.why.compat` | `uses[]` | one entry per use of a deprecated reading: `{id, where, detail, warned, since, floor, canonical, docs}`; `warned` is false in a manifest the build does not edit | +### `mcpp.why.target`, `mcpp.why.flags` and `mcpp.why.platform` *(mcpp 2026.11.9.1+)* + +``` +mcpp why target [--target ] --format json +mcpp why flags [] [--target ] --format json +mcpp why platform [--target ] --format json +``` + +`data` carries `status` and: + +| kind | field | content | +|---|---|---| +| `mcpp.why.target` | `target` | `{request, identity, provider, priority, fallback, why, candidates[]}`, each candidate `{identity, provider, priority, available, why}` (SPEC-016 §7) | +| `mcpp.why.flags` | `flags` | `{identity, units[], link[]}`: each unit `{file, package, words[]}`, each word `{word, layer, detail}`, the layer one of compiler, row, platform, toolchain, package, engine | +| `mcpp.why.platform` | `platform` | `{package, version, defines[], serves[], warnings[]}`, each served `{identity, provider, priority}`; `null` when the build uses no platform package | + `engineModules` in `mcpp.env` is the directory of the engine interface package of this mcpp version, which a build program's graph depends on (30 — `build.mcpp`); it exists once a build program has been compiled. diff --git a/docs/specs/targets-and-providers.md b/docs/specs/targets-and-providers.md index 8d5afd043..9cab7479c 100644 --- a/docs/specs/targets-and-providers.md +++ b/docs/specs/targets-and-providers.md @@ -87,7 +87,8 @@ `[platform.hosted]` 是两种简写。 - 已解析平台定义的 os 与 env 为解析器接受的词;`cfg(os = …)` 按定义的行求值。 -- 与内置行同名的定义被忽略;`mcpp check platform` 报告它为错误。 +- 读取平台清单时按行检查其语句:与内置行同名的定义、同一行的两次定义**必须**被拒绝;不服务任何行的提供者、以同一优先级 + 服务同一身份的两个提供者为警告,按清单警告的规则只在平台自身的构建中给出。 - 平台定义的行不取宿主载荷的 C 库:除非平台或项目声明 sysroot,该行没有 C 库。 - 首次遇到只在索引中的平台时,请求以临时行进入第一次准备,平台取得后重新准备。 @@ -102,7 +103,8 @@ - 状态行:`Target <请求> → <身份>`;回落时附原因。 - `mcpp why target [--format json]` 写出请求、身份、提供者、优先级、回落原因与按序的候选;`mcpp why flags []` 写出每个 - 参数及其来源层(行、平台、工具链、包、引擎)。 + 参数及其来源层(行、平台、工具链、包、引擎);`mcpp why platform` 写出平台定义的行与所服务的身份,各附提供者与优先级 + (根包是平台包时为其自身,否则为目标所选的平台)。 - 构建记录与 `plan` 事件的 `configuration.target` 为 `{request, identity, provider, priority, fallback}`;本机构建为 `null`。 - 构建程序读 `MCPP_TARGET_PROVIDER`(`mcpp::target_provider()`)。 - `mcpp.lock` 以 `[target.""] identity, provider` 记录每个请求得到的身份;`--locked` 下选择另一身份的构建被拒绝, diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index 4fd5a8312..588411ff4 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -1971,6 +1971,9 @@ discover = ["tests/**/*.cpp"] # the default | `main` | `{ from = "<依赖>/<入口>" }` | 每个测试程序的起点所在的入口目标;测试文件随之成为程序的普通翻译单元,不定义 `main`(§2.2,`kind = "entry"`) *(2026.10.26.2+)* | | `start` | 符号 | 独立目标上每个测试镜像的入口符号(§2.2,`start`) *(2026.10.26.2+)* | | `programs` | `"file"` 或 `"directory"` | `"file"`(默认):每个被发现的文件是一个程序;`"directory"`:glob 固定目录之下同一目录的文件是一个程序,以目录命名,有文件定义 `main` 时以它为起点 *(2026.11.8.1+)* | +| `split` | `"file"`、`"case"` 或 `"none"` | 使用测试协议的程序的运行方式:按用例所在文件各一个进程(默认)、每个用例一个进程,或只运行一次 *(2026.11.9.1+)* | +| `link` | `"used"` 或 `"objects"` | `"used"`(默认):测试只链接它用到的部分(`--gc-sections`、`-dead_strip`);`"objects"`:保留每个目标文件的全部内容 *(2026.11.9.1+)* | +| `store` | 目录或 `"none"` | 运行以 mtest 存储的形式保存的位置,相对项目;默认 `target/mtest`;`"none"` 不保存 *(2026.11.9.1+)* | 这些 glob 使用与 `[build] sources` 相同的词汇。一个测试的名字,是它 相对第一条匹配到它的 glob 所在的固定目录的路径,去掉扩展名。两个 diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 374d6dc9f..82ea843b8 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -583,8 +583,16 @@ updater = { path = "../updater", artifacts = ["updater"] } `mcpp = "*/run/mcpp.toml"` 时为归档中的 `run/`),因而布局相同的各版本可共用一个补丁。边上的 `patch_root = ""` 另指一个目录,相对包根,且在包的源码之内。2026.11.9 之前以归档顶层为起点写的 补丁按原写法应用,构建给出说明。 -- `mcpp patch edit <依赖>` 把构建所读的包根(已应用其补丁)复制到 `target/patch/<依赖>/`; - `mcpp patch save <依赖>` 把改动写为该边的下一个补丁 `patches/<名>-.patch`,并加入边的 `patches`。 +- `mcpp why deps` 报告每个依赖在构建中所读的包根(已应用其补丁)。补丁即 git 对该目录树的一份副本写出的差异: + + ```sh + cp -R <包根> /tmp/zlib && cd /tmp/zlib + git init -q && git add -A && git commit -qm base + # 修改之后 + git diff > <项目>/patches/0001-fix.patch + ``` + + 并由边的 `patches` 列出。再一个补丁以同样方式从打补丁后的构建所报告的包根写出,列在前者之后。 - 版本依赖与 git 依赖可以带补丁;`path` 依赖是使用者自己拥有的源码树,带补丁时被拒绝。 - `mcpp.lock` 记录变体,以及每个补丁的路径与 SHA-256;`mcpp emit sbom` 把它们写作组件的 pedigree。 - 一个程序只持有一个包的一个变体。同一配置中的两个程序(工作区的成员)可以各用自己的变体;一个 diff --git a/docs/zh/08-testing.md b/docs/zh/08-testing.md index 354dac3ad..d6ec71db5 100644 --- a/docs/zh/08-testing.md +++ b/docs/zh/08-testing.md @@ -67,6 +67,12 @@ discover = ["checks/**/*.cpp", "!checks/fixtures/**"] 的全部函数(mcpp 自身的单元测试:每个由 32 MB 降至 14 MB)。静态构造是回收的根。 `[test] link = "objects"` 保留每个目标文件的全部内容。 +一次运行以 mtest 存储的形式保存(2026.11.9.1+):`target/mtest/` 中有 `runs//run.json`, +`programs.ndjson` 每个进程一行(状态、用时、开始时刻、工作线程与所选用例),`cases.ndjson` +每个用例一行,`index.ndjson` 列出各次运行。`mtest report`、`analyze` 与 `graph --store target/mtest` +像读取 mtest 自身的运行一样读取它们:报告、最慢的用例与运行的调度。`[test] store = ""` +另指一个目录,相对项目;`"none"` 不保存。 + 由自身多个源文件编译而成的测试套件也可以是一个包:一个工作区成员,它的 `[build] sources` 承载整套套件,其唯一的测试程序驱动套件,用 `mcpp test -p ` 选中。 @@ -269,7 +275,8 @@ main = "src/main.cpp" test-protocol = 1 ``` -程序文件中带有标记 `mtest-protocol=1` 时同样如此,以 mtest 的库构建的程序即带有该标记。 +程序文件中带有标记 `mtest-protocol=1` 时同样如此,以 mtest 的库构建的程序即带有该标记;只保留可达部分的测试, +其链接所用的目标文件带有该标记也算在内,因此无人引用的标记(0.5.1 之前的 mtest)不会在链接时丢失。 带有标记却没有写出 `program` 事件的程序没有使用协议:与框架的库链接在一起的普通 `main` 文件是一个用例,按退出状态判定,运行与列出时都是如此。 mcpp 随之把协议的变量(`MTEST_PROTOCOL`、`MTEST_MODE`、`MTEST_REPORT`、`MTEST_TMPDIR`, diff --git a/docs/zh/09-commands-by-scenario.md b/docs/zh/09-commands-by-scenario.md index ed8d3fab9..834408f35 100644 --- a/docs/zh/09-commands-by-scenario.md +++ b/docs/zh/09-commands-by-scenario.md @@ -185,17 +185,21 @@ dependency graph: requested by huxdemo.comp@0.1.0 as 'fw' in [dependencies] linked static (default) instances: target + root: /work/fw ``` -`instances` 说明这个包是为目标编译、为构建程序编译,还是两者都有(2026.10.11.1+)。 +`root` 是构建所读的包根,打了补丁的依赖为其变体的包根(2026.11.9.1+;docs/05)。`instances` 说明这个包是为目标编译、为构建程序编译,还是两者都有(2026.10.11.1+)。 `mcpp why build-programs` 列出构建程序图:每个程序、它使用的库、编译所依据的身份与其二进制。 `mcpp why compat` 列出每次使用弃用读法的位置与移除它的版本(51 —— 支持的版本)。 同一张图记录在 `target///resolution.json` 的 `graph` 下, 每个包一条,根在最前:`package`(规范身份、命名空间、名字、版本、来源)、 -`root`、`requested_by`(`requester`、`key`、`table`),对库还有 `link` +`root`、`requested_by`(`requester`、`key`、`table`)、`manifest_dir`(包根),对库还有 `link` (`form`、`reason`)。 +`mcpp why target` 按序列出请求的候选(docs/21),`mcpp why flags []` 列出单元命令行的每个参数及给出它的层, +`mcpp why platform` 列出平台定义的行与所服务的身份(docs/35);后两者不属于不给话题时的报告(2026.11.9.1+)。 + 话题是 `toolchain`、`runtime`、`deps` 或 `runners`,不给话题时四者全报。 `--target` 与 `--toolchain` 把报告变成对当前目录并不使用的那一对的查询, 一个目标矩阵正是这样逐格提问的。 @@ -629,7 +633,7 @@ side_effect = false ## 当前边界 -- `mcpp why --format json` 对 `toolchain`、`sources`、`tool`、`payload`、`build-programs` 与 `compat` 话题有定义。其余话题报告 `'' has no machine-readable shape yet` 并以非零退出。 +- `mcpp why --format json` 对 `toolchain`、`sources`、`tool`、`payload`、`build-programs`、`compat`、`target`、`flags` 与 `platform` 话题有定义。其余话题报告 `'' has no machine-readable shape yet` 并以非零退出。 - `mcpp search` 按子串匹配;没有字段选择器,也没有把搜索限定到单个命名 空间的办法。 - `mcpp clean --stale` 读 `target/.build_cache`,它保存的近期条目数量 diff --git a/docs/zh/21-the-target-triple.md b/docs/zh/21-the-target-triple.md index 816c857a9..61fd58e16 100644 --- a/docs/zh/21-the-target-triple.md +++ b/docs/zh/21-the-target-triple.md @@ -487,10 +487,13 @@ docs/22。 | `x86_64-windows-msvc` | verified | — | — | — | — | 系统 | | `aarch64-macos` | verified | — | — | — | SDK | — | | `x86_64-macos` | planned | — | planned | planned | planned | planned | +| `aarch64-macos-musl` | preview | `llvm@23.1.3` | 图 | 图 | 图 | 图 | +| `x86_64-macos-musl` | preview | `llvm@23.1.3` | 图 | 图 | 图 | 图 | | `riscv64-none-elf` | verified | `llvm@23.1.3` | 载荷 | 载荷 | 载荷 | 载荷 | | `riscv32-none-elf` | verified | `llvm@23.1.3` | 载荷 | 载荷 | 载荷 | 载荷 | | `aarch64-none-elf` | preview | `llvm@23.1.3` | 载荷 | 载荷 | 载荷 | 载荷 | | `x86_64-none-elf` | preview | `llvm@23.1.3` | 载荷 | 载荷 | 载荷 | 载荷 | +| `i686-none-elf` | preview | `llvm@23.1.3` | 载荷 | 载荷 | 载荷 | 载荷 | | `thumbv6m-none-eabi` | verified | `llvm@23.1.3` | 载荷 | 载荷 | 载荷 | 载荷 | | `thumbv7m-none-eabi` | verified | `llvm@23.1.3` | 载荷 | 载荷 | 载荷 | 载荷 | | `thumbv7em-none-eabi` | preview | `llvm@23.1.3` | 载荷 | 载荷 | 载荷 | 载荷 | diff --git a/docs/zh/35-platforms.md b/docs/zh/35-platforms.md index beac0d090..55946aaa3 100644 --- a/docs/zh/35-platforms.md +++ b/docs/zh/35-platforms.md @@ -257,8 +257,10 @@ hosted = true # C 环境有 `main` 未运行」(docs/41)。行的机器描述属于平台:机器与内置行不同的目标是另一行。`[platform]` 与 `[platform.hosted]` 仍是 提供者的两种简写。 -`mcpp new <名> --template platform` 生成一个定义 `-<名>` 的平台包;`mcpp check platform [dir]` 按引擎的读法 -检查它——行、提供者以及所服务的身份与优先级——并拒绝重新定义内置行的行。 +`mcpp new <名> --template platform` 生成一个定义 `-<名>` 的平台包。读取平台清单时按行检查其语句:定义内置行、 +同一行定义两次是错误,无论平台在何处被读取;不服务任何行的提供者、以同一优先级服务同一身份的两个提供者是平台自身构建的 +警告。`mcpp why platform` 列出平台定义的行与所服务的身份,各附提供者与优先级:在平台目录中是其自身,在项目中是其目标所选 +的平台(`--target`;`--format json`)。 ## 查看一个配置 diff --git a/docs/zh/50-machine-output.md b/docs/zh/50-machine-output.md index 54eaf655d..39257f39e 100644 --- a/docs/zh/50-machine-output.md +++ b/docs/zh/50-machine-output.md @@ -450,6 +450,22 @@ mcpp why compat --format json | `mcpp.why.build-programs` | `graphs[]` | 每张构建程序图一项:`workspace`(其目录)与 `programs[]`,每个程序为 `{member, package, source, libraries[], identity, binary}` | | `mcpp.why.compat` | `uses[]` | 每次使用弃用读法一项:`{id, where, detail, warned, since, floor, canonical, docs}`;在构建不编辑的清单中 `warned` 为 false | +### `mcpp.why.target`、`mcpp.why.flags` 与 `mcpp.why.platform` *(mcpp 2026.11.9.1+)* + +``` +mcpp why target [--target ] --format json +mcpp why flags [] [--target ] --format json +mcpp why platform [--target ] --format json +``` + +`data` 带 `status`,以及: + +| kind | 字段 | 内容 | +|---|---|---| +| `mcpp.why.target` | `target` | `{request, identity, provider, priority, fallback, why, candidates[]}`,每个候选 `{identity, provider, priority, available, why}`(SPEC-016 §7) | +| `mcpp.why.flags` | `flags` | `{identity, units[], link[]}`:每个单元 `{file, package, words[]}`,每个参数 `{word, layer, detail}`,层为 compiler、row、platform、toolchain、package、engine 之一 | +| `mcpp.why.platform` | `platform` | `{package, version, defines[], serves[], warnings[]}`,每个所服务的身份 `{identity, provider, priority}`;构建不用平台包时为 `null` | + `mcpp.env` 的 `engineModules` 是本版本 mcpp 的引擎接口包的目录,构建程序图依赖它(30 —— `build.mcpp`);第一次编译构建程序之后它才存在。 diff --git a/modules/manifest/src/manifest.cppm b/modules/manifest/src/manifest.cppm index 874bb0153..fe4699884 100644 --- a/modules/manifest/src/manifest.cppm +++ b/modules/manifest/src/manifest.cppm @@ -24,6 +24,7 @@ export import mcpp.manifest.flag_words; export import mcpp.manifest.cfg_selector; export import mcpp.manifest.platform; export import mcpp.manifest.platform_toml; +export import mcpp.manifest.platform_providers; export import mcpp.manifest.key_registry; export import mcpp.pm.compat.workspace_position; export import mcpp.pm.compat.registry; diff --git a/modules/manifest/src/platform_providers.cppm b/modules/manifest/src/platform_providers.cppm new file mode 100644 index 000000000..f835d63b3 --- /dev/null +++ b/modules/manifest/src/platform_providers.cppm @@ -0,0 +1,142 @@ +// mcpp.manifest.platform_providers — what a platform's statement provides +// (SPEC-016 §3, §5). +// +// A statement's providers are its `[[platform.provider]]` tables, `[platform]` +// as the provider of its `targets`, and `[platform.hosted]` as the hosted +// provider of the systems it names. The engine registers these providers, the +// reader checks them against the rows, and `mcpp why platform` reports the +// identities they serve; all three read this one list. + +export module mcpp.manifest.platform_providers; + +import std; +import mcpp.manifest.platform; +import mcpp.toolchain.triple; +import mcpp.toolchain.target_schema; +import mcpp.toolchain.builtin_targets; + +export namespace mcpp::manifest::platform { + +struct StatedProvider { + std::string name; + std::vector serves, hosts, pairs; + int priority = 100; + std::string toolchain, sysroot, linkage; + bool shorthand = false; // `[platform]` or `[platform.hosted]` +}; + +// `name` is the platform's name (`platform_name`), the name of every provider +// that states none. +std::vector stated_providers(const Statement& s, std::string_view name); + +// One identity a provider serves, with the provider's priority. +struct ServedIdentity { + std::string identity, provider; + int priority = 0; +}; + +// The identities the statement's providers serve among the built-in rows and the +// rows it defines, by identity, then priority (highest first), then provider. +std::vector served_identities(const Statement& s, std::string_view name); + +// The statement checked against the rows: a row defined twice, or a definition +// of a built-in row, is an error; a provider that serves no row, and two +// providers that serve one identity at one priority, are warnings. +struct StatementFindings { + std::vector errors, warnings; +}; +StatementFindings check_statement(const Statement& s, std::string_view name); + +} // namespace mcpp::manifest::platform + +namespace mcpp::manifest::platform { + +namespace { + +namespace target = mcpp::toolchain::target; + +bool builtin_row(std::string_view triple) { + return std::ranges::any_of(mcpp::toolchain::builtin::kRows, + [&](auto const& r) { return r.triple == triple; }); +} + +// The rows a pattern matches: the statement's own, then the built-in ones. +std::vector rows_matching(const Statement& s, std::string_view pattern) { + std::vector out; + for (auto const& d : s.defines) + if (target::serves_triple(pattern, d.triple) && !std::ranges::contains(out, d.triple)) + out.push_back(d.triple); + for (auto const& r : mcpp::toolchain::builtin::kRows) + if (target::serves_triple(pattern, r.triple) && !std::ranges::contains(out, r.triple)) + out.emplace_back(r.triple); + return out; +} + +} // namespace + +std::vector stated_providers(const Statement& s, std::string_view name) { + std::vector out; + for (auto const& p : s.providers) + out.push_back({ p.name.empty() ? std::string(name) : p.name, p.serves, p.hosts, p.pairs, p.priority, + p.toolchain.empty() ? s.toolchain.value : p.toolchain, p.sysroot, p.linkage, false }); + if (s.targets.set) + out.push_back({ std::string(name), s.targets.value, {"*"}, {}, s.prioritySet ? s.priority : 100, + s.toolchain.value, {}, {}, true }); + // A HOSTED ENTRY NAMES A SYSTEM; THE C LIBRARY NAMES THE ENV: it serves every + // identity of that architecture and system. + if (s.hosted.set) { + std::vector serves; + for (auto const& w : s.hosted.targets.value) + if (auto t = mcpp::toolchain::triple::parse(w)) { + serves.push_back(std::format("{}-{}-*", t->arch, t->os)); + serves.push_back(std::format("{}-{}", t->arch, t->os)); + } + if (!s.hosted.targets.set) serves = {"*-*-*"}; + out.push_back({ std::string(name), std::move(serves), {"*"}, {}, s.prioritySet ? s.priority - 50 : 50, + s.hosted.toolchain.set ? s.hosted.toolchain.value : s.toolchain.value, {}, {}, true }); + } + return out; +} + +std::vector served_identities(const Statement& s, std::string_view name) { + std::vector out; + for (auto const& p : stated_providers(s, name)) { + std::set seen; + for (auto const& pattern : p.serves) + for (auto& id : rows_matching(s, pattern)) + if (seen.insert(id).second) out.push_back({ std::move(id), p.name, p.priority }); + } + std::ranges::sort(out, [](auto const& a, auto const& b) { + return std::tie(a.identity, b.priority, a.provider) < std::tie(b.identity, a.priority, b.provider); + }); + return out; +} + +StatementFindings check_statement(const Statement& s, std::string_view name) { + StatementFindings f; + std::set own; + for (auto const& d : s.defines) { + if (builtin_row(d.triple)) + f.errors.push_back(std::format("[[platform.define]] '{}' is a built-in row; a platform serves it " + "with [[platform.provider]] serves = [\"{}\"]", d.triple, d.triple)); + else if (!own.insert(d.triple).second) + f.errors.push_back(std::format("[[platform.define]] '{}' is defined twice", d.triple)); + } + for (auto const& p : stated_providers(s, name)) { + if (p.shorthand) continue; + if (std::ranges::none_of(p.serves, [&](auto const& pattern) { return !rows_matching(s, pattern).empty(); })) + f.warnings.push_back(std::format("[[platform.provider]] '{}' serves no row ({})", p.name, + p.serves.empty() ? std::string("serves is empty") : p.serves.front())); + } + const auto served = served_identities(s, name); + for (std::size_t i = 1; i < served.size(); ++i) { + auto const& a = served[i - 1]; + auto const& b = served[i]; + if (a.identity == b.identity && a.priority == b.priority && a.provider != b.provider) + f.warnings.push_back(std::format("'{}' is served by '{}' and '{}' at priority {}: the name orders them", + b.identity, a.provider, b.provider, b.priority)); + } + return f; +} + +} // namespace mcpp::manifest::platform diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 84cdc830d..cf91b4b25 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -7,6 +7,7 @@ import mcpp.manifest.key_registry; import mcpp.manifest.cfg_selector; import mcpp.manifest.platform; import mcpp.manifest.platform_toml; +import mcpp.manifest.platform_providers; import mcpp.targetside; import std; import mcpp.invocation; @@ -589,8 +590,6 @@ struct DependencyTextEdit { std::string shortName; std::string version; std::vector features; - // The edge's patches, as the manifest writes them (relative to it). - std::vector patches; bool dev = false; }; @@ -3704,6 +3703,13 @@ std::expected parse_document(const t::Document& documen m.testPrograms = value.as_string(); continue; } + if (key == "store") { + if (!value.is_string() || value.as_string().empty()) + return std::unexpected(error(origin, + "[test] store is a directory relative to the project, or \"none\"")); + m.testStore = value.as_string(); + continue; + } if (key == "split") { if (!value.is_string() || (value.as_string() != "file" && value.as_string() != "case" && value.as_string() != "none")) @@ -3724,7 +3730,7 @@ std::expected parse_document(const t::Document& documen if (key != "discover") { m.schemaWarnings.push_back(std::format( "[test] has unsupported key '{}' (ignored). Supported keys: " - "discover, link, main, programs, split, start, windows_code_page, windows_long_paths.", key)); + "discover, link, main, programs, split, start, store, windows_code_page, windows_long_paths.", key)); continue; } bool ok = value.is_array(); @@ -3923,6 +3929,13 @@ std::expected parse_document(const t::Document& documen return std::unexpected(error(origin, "[platform] names the targets it serves: targets = [\"\", ...], or its " "providers: [[platform.provider]] serves = [...]")); + // The rows it defines and the providers it states, checked against the + // rows as the engine reads them (SPEC-016 §5). + auto findings = mcpp::manifest::platform::check_statement( + *st, mcpp::manifest::platform::platform_name(m.package.name)); + if (!findings.errors.empty()) + return std::unexpected(error(origin, findings.errors.front())); + for (auto& w : findings.warnings) m.schemaWarnings.push_back(std::move(w)); m.platform = std::move(*st); m.platformDeclared = true; } @@ -5754,11 +5767,6 @@ upsert_dependency_text(std::string_view source, } } - for (auto const& p : edit.patches) - for (unsigned char ch : p) - if (ch < 0x20 || ch == 0x7f || ch == '"' || ch == '\\') - return std::unexpected(std::format( - "dependency patch '{}' contains an unsafe TOML character", p)); auto quoted_list = [](const std::vector& items) { std::string list; for (auto const& item : items) { @@ -5768,13 +5776,11 @@ upsert_dependency_text(std::string_view source, return list; }; std::string value; - if (edit.features.empty() && edit.patches.empty()) { + if (edit.features.empty()) { value = std::format("\"{}\"", edit.version); } else { - value = std::format("{{ version = \"{}\"", edit.version); - if (!edit.features.empty()) value += std::format(", features = [{}]", quoted_list(edit.features)); - if (!edit.patches.empty()) value += std::format(", patches = [{}]", quoted_list(edit.patches)); - value += " }"; + value = std::format("{{ version = \"{}\", features = [{}] }}", edit.version, + quoted_list(edit.features)); } struct SectionSpan { diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 3b8a6074a..e92dfc21b 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -2260,6 +2260,10 @@ struct Manifest { // the protocol is run -- "file" (the default: a process per file of its // cases), "case" (a process per case) or "none" (one process). std::string testSplit; + // `[test] store` (design 2026-11-09 §17.3): the directory `mcpp test` + // writes its runs to in the mtest store's form (mtest P12), relative to the + // project; empty: `target/mtest`; "none": no store. + std::string testStore; // Set once a workspace root's own package has received `[workspace.package]` // and `[workspace.build]` (`mcpp::project::inherit_as_root_package`). The // manifest is read on two paths before it is planned, and an appended diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 229aff4eb..b4886a800 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -4056,6 +4056,12 @@ struct TestResult { // said: each case's result, or its listing under `--cases`. int protocol = 0; mcpp::build::test_protocol::Report report; + // Where it ran in the run's schedule, for the mtest store (P12): its start + // after the command began, the worker that ran it, and the cases it + // selected (a program run as a process per file of its cases). + long long startedMs = -1; + int worker = -1; + std::vector select; // The program and the files it is compiled from, for a test built and not // run (`--no-run`): what a runner that selects by change reads (docs/50). std::string program; @@ -4064,6 +4070,75 @@ struct TestResult { std::string guide; }; +// THE RUN IN THE MTEST STORE (mtest spec P12; design 2026-11-09 §17.3): the +// run's record, a line per process of a program and a line per case, so that +// `mtest report|analyze|graph --store ` read `mcpp test`'s runs as the +// engine's own. Written whole into `runs//` and then appended to the index. +void write_mtest_store(const std::filesystem::path& dir, const std::vector& results, + std::chrono::system_clock::time_point started, long long ms, int jobs, + std::string_view selection) { + namespace fs = std::filesystem; + using nlohmann::json; + const auto finished = std::chrono::system_clock::now(); + auto iso = [](std::chrono::system_clock::time_point t) { + return std::format("{:%Y-%m-%dT%H:%M:%SZ}", std::chrono::floor(t)); + }; + static std::atomic serial{0}; + const auto id = std::format("{:%Y%m%dT%H%M%S}-{:04x}", std::chrono::floor(started), + (std::random_device{}() ^ serial++) & 0xffff); + const auto runDir = dir / "runs" / id; + std::error_code ec; + fs::create_directories(runDir, ec); + if (ec) return; + auto status_of = [](const TestResult& t) { + switch (t.status) { + case TestResult::St::Pass: return "pass"; + case TestResult::St::RunFail: return "fail"; + case TestResult::St::CompileFail: return "build-fail"; + case TestResult::St::NotRun: return "not-run"; + case TestResult::St::Built: return "built"; + } + return "fail"; + }; + std::map counts; + bool failed = false; + { + std::ofstream programs(runDir / "programs.ndjson", std::ios::binary); + std::ofstream cases(runDir / "cases.ndjson", std::ios::binary); + for (auto const& t : results) { + failed |= t.status == TestResult::St::RunFail || t.status == TestResult::St::CompileFail; + json p{{"name", t.name}, {"argv", json::array({t.program.empty() ? t.name : t.program})}, + {"status", status_of(t)}, {"exit", t.exitCode}, {"ms", t.durationMs}, + {"started_ms", t.startedMs}, {"worker", t.worker}, {"select", t.select}, + {"resources", json::array()}, {"fixtures", json::array()}}; + if (t.buildMs > 0) p["build_ms"] = t.buildMs; + programs << p.dump() << "\n"; + if (t.report.cases.empty()) { + ++counts[status_of(t)]; + continue; + } + for (auto const& c : t.report.cases) { + ++counts[c.status]; + json f = json::array(); + for (auto const& x : c.failures) + f.push_back({{"file", x.file}, {"line", x.line}, {"message", x.message}}); + cases << json{{"program", t.name}, {"id", c.id}, {"status", c.status}, {"ms", c.ms}, + {"reason", c.reason}, {"failures", f}}.dump() << "\n"; + } + } + } + json run{{"schema", "mtest.run/1"}, {"id", id}, {"started", iso(started)}, {"finished", iso(finished)}, + {"status", failed ? "fail" : "pass"}, + {"system", std::string(mcpplibs::os::name(mcpplibs::os::PLATFORM))}, + {"arch", std::string(mcpplibs::os::name(mcpplibs::os::ARCH))}, {"machine", ""}, {"commit", ""}, + {"set", "mcpp test"}, {"selection", std::string(selection)}, + {"group", mcpp::invocation::get("GITHUB_RUN_ID").value_or("")}, {"monitor", "off"}, + {"ms", ms}, {"jobs", jobs}, {"counts", counts}}; + { std::ofstream(runDir / "run.json", std::ios::binary) << run.dump(2) << "\n"; } + std::ofstream(dir / "index.ndjson", std::ios::binary | std::ios::app) << run.dump() << "\n"; +} + + // Streaming NDJSON: one record per test, emitted as it finishes — a // consumer (e.g. the d2x provider) sees progress live, and a crash // mid-run still leaves the completed records on stdout. @@ -4562,6 +4637,7 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, auto* ctx = &*tb.ctx; auto& backend = tb.backend; const auto tLoop = std::chrono::steady_clock::now(); + const auto testStartedWall = std::chrono::system_clock::now(); if (tb.runnerFatal) return tb.runnerFatal; const auto& runnerChoice = tb.runnerChoice; auto& runnerTmpl = tb.runnerTmpl; @@ -4742,11 +4818,22 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, std::atomic next{0}; std::mutex reportMutex; - auto worker = [&] { + auto worker = [&](int workerId) { for (;;) { std::size_t i = next.fetch_add(1); if (i >= list.size()) return; auto& r = list[i]; + // The schedule, recorded with the result (the mtest store). + const auto stamp_schedule = [&, startedAt = std::chrono::steady_clock::now()] { + auto& t = results.back(); + t.startedMs = std::chrono::duration_cast(startedAt - tLoop).count(); + t.worker = workerId; + if (r.select) { + std::ifstream in(*r.select); + for (std::string line; std::getline(in, line);) + if (!line.empty()) t.select.push_back(line); + } + }; // Stamped HERE, immediately before the exec — not when the // test was queued. @@ -4851,6 +4938,7 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, if (!json) mcpp::ui::plain(std::format("{} ... not run", r.name)); results.push_back({r.name, TestResult::St::NotRun, 0, {}, {}, ms, false, reason, r.buildMs}); + stamp_schedule(); std::fflush(libc::stdout()); emit_json(results.back()); continue; @@ -4874,6 +4962,7 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, } results.back().protocol = spoken; results.back().report = std::move(report); + stamp_schedule(); results.back().guide = r.guide; // The captured output belongs directly under its own line, or // it is attributable to nothing. @@ -4891,11 +4980,11 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, }; if (workers <= 1) { - worker(); + worker(0); } else { std::vector pool; pool.reserve(static_cast(workers)); - for (int w = 0; w < workers; ++w) pool.emplace_back(worker); + for (int w = 0; w < workers; ++w) pool.emplace_back(worker, w); for (auto& t : pool) t.join(); } // WALL time of the phase, not the sum of the per-test durations: with @@ -4958,6 +5047,25 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, if (!std::isalnum(static_cast(ch)) && ch != '-' && ch != '.') ch = '_'; return ctx->outputDir / "test-protocol" / leaf; }; + // THE MARKER OF THE OBJECTS A TEST WAS LINKED FROM (P2.3). A test keeps what + // it reaches, and a framework whose marker nothing references (mtest before + // 0.5.1) loses it at the link; its object still carries it, and the program + // is then read as it was before the link dropped anything. Each object is + // read once per run. + std::map objectMarkers; + auto marker_of_objects = [&](const mcpp::build::LinkUnit& lu) { + const bool collected = std::ranges::any_of(lu.linkFlags, [](std::string_view f) { + return f == "-Wl,--gc-sections" || f == "-Wl,-dead_strip"; + }); + if (!collected) return 0; + for (auto const& o : lu.objects) { + const auto path = o.is_absolute() ? o : ctx->outputDir / o; + auto [it, fresh] = objectMarkers.try_emplace(path, 0); + if (fresh) it->second = mcpp::build::test_protocol::marker_version(path); + if (it->second == mcpp::build::test_protocol::kVersion) return it->second; + } + return 0; + }; for (auto& lu : ctx->plan.linkUnits) { if (!filter_match(lu)) continue; @@ -5075,8 +5183,9 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // its file carries (P2.3). A version this mcpp does not speak runs as // a program that implements none. auto& added = runnable.back(); - const int version = lu.testProtocol ? lu.testProtocol - : mcpp::build::test_protocol::marker_version(exe); + int version = lu.testProtocol ? lu.testProtocol + : mcpp::build::test_protocol::marker_version(exe); + if (version == 0) version = marker_of_objects(lu); if (version == mcpp::build::test_protocol::kVersion) { added.protocol = version; added.protocolDir = protocol_dir(lu.targetName); @@ -5276,6 +5385,13 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, runnable = std::move(expanded); } run_tests_now(runnable); + // The run, kept in the mtest store's form (`[test] store`). + if (const auto& st = ctx->manifest.testStore; st != "none" && !results.empty()) + write_mtest_store(st.empty() ? ctx->projectRoot / "target" / "mtest" : ctx->projectRoot / st, results, + testStartedWall, + std::chrono::duration_cast( + std::chrono::steady_clock::now() - tLoop).count(), + runJobs, testOpts.filter); } summary.elapsedMs = carriedMs + std::chrono::duration_cast( std::chrono::steady_clock::now() - tLoop).count(); diff --git a/src/build/flag_origins.cpp b/src/build/flag_origins.cpp index c6c3a4579..7b2083024 100644 --- a/src/build/flag_origins.cpp +++ b/src/build/flag_origins.cpp @@ -3,7 +3,7 @@ // the interface's importers: with them imported by the interface, GCC 16.1 // ended with an internal compiler error (a segmentation fault) at // `import mcpp.cli;` in main.cpp, which imports the interface through -// mcpp.doctor (measured 2026-11-09). +// mcpp.doctor (measured 2026-11-09; mcpp#848). module mcpp.build.flag_origins; import std; diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 0f24d118d..c812f1082 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -610,9 +610,6 @@ export struct BuildContext { CompilerChoice compilerChoice; // WHICH ROW AND WHICH PROVIDER THE REQUEST WAS GIVEN (SPEC-016 §4). TargetSelection targetSelection; - // Each package's root as this build read it, by name and by qualified - // name: a patched dependency's is its variant's (`mcpp patch edit`). - std::map packageRoots; // THE SOURCES THIS BUILD USED (mcpp#755), one entry per subject. See // SourceDecision. std::vector sources; diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index c6a25f781..29bb63933 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -2188,11 +2188,6 @@ std::expected phase13_finish(PrepareState& state) { ctx.runtimeBinding = state.targetRuntimeBinding ? *state.targetRuntimeBinding : state.runtimeBindingSnapshot; ctx.runUnder = state.runUnder; - for (auto const& p : state.packages) { - ctx.packageRoots.try_emplace(p.manifest.package.name, p.root); - if (!p.manifest.package.namespace_.empty()) - ctx.packageRoots.try_emplace(p.manifest.package.namespace_ + "." + p.manifest.package.name, p.root); - } { auto const& c = state.targetChoice; auto& s = ctx.targetSelection; diff --git a/src/build/prepare/platform.cpp b/src/build/prepare/platform.cpp index 5c9de3438..a283077ad 100644 --- a/src/build/prepare/platform.cpp +++ b/src/build/prepare/platform.cpp @@ -237,23 +237,9 @@ void define_platform(const mp::Statement& s, std::string_view shortName, std::st auto r = b.platform.empty() ? std::nullopt : std::optional(mp::parse_ref_text(b.platform)); return r && *r && (*r)->spec.shortName == shortName; }); - for (auto const& p : s.providers) - provider(p.name.empty() ? name : p.name, p.serves, p.priority, - p.toolchain.empty() ? s.toolchain.value : p.toolchain, p.hosts, p.pairs, p.sysroot, - p.linkage); - if (s.targets.set && !builtin) - provider(name, s.targets.value, s.prioritySet ? s.priority : 100, s.toolchain.value, {}, {}, {}, {}); - // A HOSTED ENTRY NAMES A SYSTEM; THE C LIBRARY NAMES THE ENV (I5): it serves - // every identity of that architecture and system. - if (s.hosted.set && !builtin) { - std::vector serves; - for (auto const& w : s.hosted.targets.value) - if (auto t = mcpp::toolchain::triple::parse(w)) - serves.push_back(std::format("{}-{}-*", t->arch, t->os)), serves.push_back(std::format("{}-{}", t->arch, t->os)); - if (!s.hosted.targets.set) serves = {"*-*-*"}; - provider(name, serves, s.prioritySet ? s.priority - 50 : 50, - s.hosted.toolchain.set ? s.hosted.toolchain.value : s.toolchain.value, {}, {}, {}, {}); - } + for (auto const& p : mp::stated_providers(s, name)) + if (!p.shorthand || !builtin) + provider(p.name, p.serves, p.priority, p.toolchain, p.hosts, p.pairs, p.sysroot, p.linkage); target::define(std::move(set)); } diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp index 70b6c024a..c6375763f 100644 --- a/src/build/prepare/target_side.cpp +++ b/src/build/prepare/target_side.cpp @@ -1779,6 +1779,11 @@ static void step9_define_graph_package_entry_closure(PrepareState& state) { // resolution.json only: the graph document a build program reads is // part of its re-run key, and must not change with when it is asked. if (!forBuildProgram) { + // The package's root as the build reads it (its patches applied): + // the tree a patch of it is written from (docs/05). + std::error_code ec; + entry["manifest_dir"] = std::filesystem::absolute(state.packages[i].root, ec) + .lexically_normal().generic_string(); static const std::vector none; // A member's program keeps its cache in the project (step 9). const bool member = state.m->package.virtualRoot && i != 0 diff --git a/src/cli.cppm b/src/cli.cppm index 6adcbb7d2..9d3fcc36f 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -19,8 +19,6 @@ import mcpp.build.schedule.detach_codegen; import mcpplibs.cmdline; import mcpp.cli.cmd_build; import mcpp.cli.cmd_cache; -import mcpp.cli.cmd_patch; -import mcpp.cli.cmd_check; import mcpp.cli.cmd_new; import mcpp.cli.cmd_publish; import mcpp.cli.cmd_xpkg; @@ -77,8 +75,6 @@ void print_usage() { std::println(" mcpp clean [--stale] [--bmi-cache] Remove target/ (or, with --stale, only its non-current fingerprint dirs)"); std::println(" mcpp add [ns.]pkg@ver Add an exact dependency to mcpp.toml"); std::println(" mcpp remove [ns.]pkg Remove an exact dependency from mcpp.toml"); - std::println(" mcpp patch edit|save Edit a dependency's sources; save the change as its patch"); - std::println(" mcpp check platform [dir] Check a platform package's rows, providers and served targets"); std::println(" mcpp update [pkg] Re-resolve deps and rewrite mcpp.lock"); std::println(" mcpp search Search packages in registries"); std::println(" mcpp publish [--dry-run] Publish package to default registry"); @@ -941,7 +937,7 @@ int run(int argc, char** argv) { .action(wrap_rc(cmd_clean))) .subcommand(cl::App("why") .description("Explain how the target / toolchain / runtime / deps / runners / sources / flags were resolved") - .arg(cl::Arg("topic").help("target | toolchain | sysroot | runtime | deps | runners | sources | tool | payload | build-programs | compat | flags (default: all but flags)")) + .arg(cl::Arg("topic").help("target | toolchain | sysroot | runtime | deps | runners | sources | tool | payload | build-programs | compat | flags | platform (default: all but flags and platform)")) .arg(cl::Arg("subject").help("for `tool` and `payload`: the name to look up; for `flags`: the unit, by the end of its path")) // `--target` / `--toolchain` make this a QUERY rather than a // report on the current directory's default: "what would a build @@ -1225,30 +1221,6 @@ int run(int argc, char** argv) { .option(cl::Option("target").takes_value().help( "Remove the payload for instead of the host one"))) .action(wrap_rc(cmd_toolchain_any))) - .subcommand(cl::App("check") - .description("Check a package's statement as the engine reads it") - .subcommand(cl::App("platform") - .description("Check a platform package: its rows, its providers and the identities it serves") - .arg(cl::Arg("dir").help("The platform package's directory (default: this one)"))) - .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { - return dispatch_sub("check", p, { - {"platform", cmd_check_platform}, - }); - }))) - .subcommand(cl::App("patch") - .description("Edit a dependency's sources and save the change as its patch") - .subcommand(cl::App("edit") - .description("Copy the dependency's package root, as this build reads it, to target/patch/") - .arg(cl::Arg("dependency").help("The dependency, as [dependencies] names it").required())) - .subcommand(cl::App("save") - .description("Write the edit as the dependency's next patch under patches/ and add it to the edge") - .arg(cl::Arg("dependency").help("The dependency, as [dependencies] names it").required())) - .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { - return dispatch_sub("patch", p, { - {"edit", cmd_patch_edit}, - {"save", cmd_patch_save}, - }); - }))) .subcommand(cl::App("cache") .description("Inspect and manage the global build cache") .subcommand(cl::App("dir") @@ -1952,7 +1924,7 @@ int run(int argc, char** argv) { static constexpr std::array known = std::to_array({ "new", "build", "run", "test", "clean", "add", "remove", "update", "search", "publish", "pack", "emit", "xpkg", - "toolchain", "cache", "patch", "check", "index", "self", "explain", + "toolchain", "cache", "index", "self", "explain", "version", "dyndep", "why", "resolve", "stage", "bmi-equal", "coff-def", "place-dlls", "link-output", "platform-step", "bmi-compile", "bmi-supervise", "bmi-await", diff --git a/src/cli/cmd_check.cpp b/src/cli/cmd_check.cpp deleted file mode 100644 index c783d59dc..000000000 --- a/src/cli/cmd_check.cpp +++ /dev/null @@ -1,121 +0,0 @@ -// mcpp.cli.cmd_check — the implementation, an implementation unit for the -// reason mcpp.cli.cmd_patch states. -module mcpp.cli.cmd_check; - -import std; -import mcpplibs.cmdline; -import mcpp.manifest; -import mcpp.toolchain.target_schema; -import mcpp.toolchain.target_rows; -import mcpp.ui; - -namespace mcpp::cli { - -namespace { - -namespace fs = std::filesystem; -namespace mp = mcpp::manifest::platform; -namespace target = mcpp::toolchain::target; - -std::vector segments(std::string_view triple) { - std::vector out; - for (auto part : std::views::split(triple, '-')) out.emplace_back(part.begin(), part.end()); - return out; -} - -bool word(std::string_view w) { - return !w.empty() && std::ranges::all_of(w, [](char c) { - return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.'; - }); -} - -} // namespace - -int cmd_check_platform(const mcpplibs::cmdline::ParsedArgs& parsed) { - const fs::path dir = parsed.positional(0).empty() ? fs::current_path() : fs::path(parsed.positional(0)); - auto m = mcpp::manifest::load(dir / "mcpp.toml"); - if (!m) { - mcpp::ui::error(m.error().format()); - return 1; - } - if (!m->platformDeclared) { - mcpp::ui::error(std::format("{} states no [platform]: it is not a platform package", - (dir / "mcpp.toml").string())); - return 1; - } - for (auto const& w : m->schemaWarnings) mcpp::ui::warning(w); - auto const& s = m->platform; - std::vector errors, warnings; - std::set archs, own; - for (auto const* r : target::rows()) archs.insert(std::string(r->arch)); - - for (auto const& d : s.defines) { - const auto seg = segments(d.triple); - if (seg.size() < 2 || seg.size() > 3 || !std::ranges::all_of(seg, word)) { - errors.push_back(std::format("[[platform.define]] '{}' is not -[-] of words", d.triple)); - continue; - } - if (!archs.contains(seg[0])) - warnings.push_back(std::format("'{}': no built-in row has the architecture '{}'", d.triple, seg[0])); - if (target::find_row(d.triple) && !own.contains(d.triple)) - errors.push_back(std::format("'{}' is a built-in row; a platform serves it with " - "[[platform.provider]] serves = [\"{}\"]", d.triple, d.triple)); - if (!own.insert(d.triple).second) - errors.push_back(std::format("'{}' is defined twice", d.triple)); - if (d.clangTriple.empty()) - warnings.push_back(std::format("'{}' states no clang-triple: the compiler is given the row's own spelling", - d.triple)); - } - - // Each identity this platform serves, with the provider and priority. - struct Served { std::string identity, provider; int priority; }; - std::vector served; - auto rows_matching = [&](std::string_view pattern) { - std::vector out; - for (auto const& t : own) if (target::serves_triple(pattern, t)) out.push_back(t); - for (auto const* r : target::rows()) - if (target::serves_triple(pattern, r->triple) && !own.contains(std::string(r->triple))) - out.emplace_back(r->triple); - return out; - }; - const std::string platformName = m->package.name; - if (s.targets.set) - for (auto const& t : s.targets.value) - for (auto const& id : rows_matching(t)) - served.push_back({ id, platformName, s.prioritySet ? s.priority : 100 }); - for (auto const& p : s.providers) { - const auto name = p.name.empty() ? platformName : p.name; - std::size_t n = 0; - for (auto const& pattern : p.serves) - for (auto const& id : rows_matching(pattern)) { - served.push_back({ id, name, p.priority }); - ++n; - } - if (n == 0) - warnings.push_back(std::format("the provider '{}' serves no row ({})", name, - p.serves.empty() ? std::string("serves is empty") : p.serves.front())); - } - std::ranges::sort(served, [](auto const& a, auto const& b) { - return std::tie(a.identity, b.priority, a.provider) < std::tie(b.identity, a.priority, b.provider); - }); - for (std::size_t i = 1; i < served.size(); ++i) - if (served[i].identity == served[i - 1].identity && served[i].priority == served[i - 1].priority - && served[i].provider != served[i - 1].provider) - warnings.push_back(std::format("'{}' is served by '{}' and '{}' at priority {}: the name orders them", - served[i].identity, served[i - 1].provider, served[i].provider, - served[i].priority)); - if (served.empty()) errors.push_back("the platform serves no identity: state targets or [[platform.provider]] serves"); - - mcpp::ui::info("Platform", std::format("{} {}", m->package.name, m->package.version)); - for (auto const& e : served) - mcpp::ui::line(std::format(" serves {:<28} {:<20} priority {}", e.identity, e.provider, e.priority)); - for (auto const& w : warnings) mcpp::ui::warning(w); - for (auto const& e : errors) mcpp::ui::error(e); - if (!errors.empty()) return 1; - mcpp::ui::info("Checked", std::format("{} row(s) defined, {} identit{} served{}", own.size(), served.size(), - served.size() == 1 ? "y" : "ies", - warnings.empty() ? "" : std::format(", {} warning(s)", warnings.size()))); - return 0; -} - -} // namespace mcpp::cli diff --git a/src/cli/cmd_check.cppm b/src/cli/cmd_check.cppm deleted file mode 100644 index e4e4fc3e1..000000000 --- a/src/cli/cmd_check.cppm +++ /dev/null @@ -1,18 +0,0 @@ -// mcpp.cli.cmd_check — `mcpp check platform []` (SPEC-016 §2, §3). -// -// A platform package's statement checked as the engine reads it: the rows it -// defines (their segments, that they name no built-in row, that none repeats), -// the providers (that each serves a row, and which share a row at one -// priority), and the identities it serves, each with its provider and -// priority. Errors end the command with 1; warnings do not. - -export module mcpp.cli.cmd_check; - -import std; -import mcpplibs.cmdline; - -export namespace mcpp::cli { - -int cmd_check_platform(const mcpplibs::cmdline::ParsedArgs& parsed); - -} // namespace mcpp::cli diff --git a/src/cli/cmd_patch.cpp b/src/cli/cmd_patch.cpp deleted file mode 100644 index d627416c7..000000000 --- a/src/cli/cmd_patch.cpp +++ /dev/null @@ -1,147 +0,0 @@ -// mcpp.cli.cmd_patch — the implementation. An implementation unit, so that the -// modules it reads are not reachable from the CLI's interface: imported by the -// interface, GCC 16.1 ended with an internal compiler error at `import mcpp.cli;` -// in main.cpp (measured 2026-11-09, as for mcpp.build.flag_origins). -module mcpp.cli.cmd_patch; - -import std; -import mcpplibs.cmdline; -import mcpp.build.prepare; -import mcpp.manifest; -import mcpp.project; -import mcpp.pm.patch; -import mcpp.ui; - -namespace mcpp::cli { - -namespace { - -namespace fs = std::filesystem; - -struct Edge { - std::string key; - mcpp::manifest::DependencySpec spec; -}; - -// The root's edge to `name`: its key, or its short name. -std::optional edge_of(const mcpp::manifest::Manifest& m, std::string_view name) { - for (auto const& [key, spec] : m.dependencies) - if (key == name || spec.shortName == name) return Edge{ key, spec }; - return std::nullopt; -} - -fs::path edit_dir(const fs::path& project, std::string_view key) { - return project / "target" / "patch" / std::string(key); -} - -fs::path base_dir(const fs::path& project, std::string_view key) { - return project / "target" / "patch" / (std::string(key) + ".base"); -} - -int fail(std::string_view text) { - mcpp::ui::error(text); - return 2; -} - -} // namespace - -int cmd_patch_edit(const mcpplibs::cmdline::ParsedArgs& parsed) { - const std::string name = parsed.positional(0); - if (name.empty()) return fail("mcpp patch edit needs a dependency: mcpp patch edit "); - auto ctx = mcpp::build::prepare_build(false, false, {}, {}); - if (!ctx) return fail(ctx.error()); - auto edge = edge_of(ctx->manifest, name); - if (!edge) return fail(std::format("'{}' is not a dependency of {}", name, ctx->manifest.package.name)); - if (edge->spec.isPath()) - return fail(std::format("'{}' is a path dependency: its tree is the project's to edit", name)); - // The package by its name, or a patched variant's (`+p`). - auto root = std::ranges::find_if(ctx->packageRoots, [&](auto const& e) { - for (auto const& n : { edge->spec.shortName, edge->spec.namespace_ + "." + edge->spec.shortName }) - if (e.first == n || e.first.starts_with(n + "+p")) return true; - return false; - }); - if (root == ctx->packageRoots.end()) return fail(std::format("the build did not read '{}'", name)); - const auto work = edit_dir(ctx->projectRoot, edge->key), base = base_dir(ctx->projectRoot, edge->key); - std::error_code ec; - if (fs::exists(work, ec)) - return fail(std::format("'{}' is being edited at {}: `mcpp patch save {}` writes its patch", name, - work.string(), name)); - fs::create_directories(work.parent_path(), ec); - const auto real = fs::weakly_canonical(root->second, ec); - for (auto const& to : { base, work }) { - fs::copy(real, to, fs::copy_options::recursive, ec); - if (ec) return fail(std::format("cannot copy {} to {}: {}", real.string(), to.string(), ec.message())); - } - mcpp::ui::info("Editing", std::format("{} at {}", name, work.string())); - mcpp::ui::line(std::format(" the tree is the package's root, its {} patch(es) applied; " - "`mcpp patch save {}` writes the change", edge->spec.patches.size(), name)); - return 0; -} - -int cmd_patch_save(const mcpplibs::cmdline::ParsedArgs& parsed) { - const std::string name = parsed.positional(0); - if (name.empty()) return fail("mcpp patch save needs a dependency: mcpp patch save "); - auto project = mcpp::project::find_manifest_root(fs::current_path()); - if (!project) return fail("no mcpp.toml in this directory or its parents"); - const auto manifestPath = *project / "mcpp.toml"; - auto m = mcpp::manifest::load(manifestPath); - if (!m) return fail(m.error().format()); - auto edge = edge_of(*m, name); - if (!edge) return fail(std::format("'{}' is not a dependency of {}", name, m->package.name)); - const auto work = edit_dir(*project, edge->key), base = base_dir(*project, edge->key); - std::error_code ec; - if (!fs::is_directory(work, ec) || !fs::is_directory(base, ec)) - return fail(std::format("'{}' is not being edited: `mcpp patch edit {}` begins", name, name)); - const auto diff = mcpp::pm::patch::diff_trees(base, work); - if (diff.empty()) { - mcpp::ui::info("Unchanged", std::format("{}: the tree is as it was; no patch is written", name)); - return 0; - } - // The next patch of the edge, named after the package and its place in the set. - const auto stem = edge->spec.shortName.empty() ? edge->key : edge->spec.shortName; - fs::path rel; - for (std::size_t n = edge->spec.patches.size() + 1;; ++n) { - rel = fs::path("patches") / std::format("{}-{:04}.patch", stem, n); - if (!fs::exists(*project / rel, ec)) break; - } - fs::create_directories((*project / rel).parent_path(), ec); - { - std::ofstream out(*project / rel, std::ios::binary); - out << diff; - if (!out) return fail(std::format("cannot write {}", (*project / rel).string())); - } - std::vector patches; - for (auto const& p : edge->spec.patches) - patches.push_back(fs::path(p).lexically_relative(*project).generic_string()); - patches.push_back(rel.generic_string()); - bool recorded = false; - if (!edge->spec.isGit() && !edge->spec.version.empty()) { - std::ifstream in(manifestPath, std::ios::binary); - const std::string text{std::istreambuf_iterator(in), {}}; - auto edited = mcpp::manifest::upsert_dependency_text(text, { - .namespace_ = edge->spec.namespace_.empty() ? std::string("mcpplibs") : edge->spec.namespace_, - .shortName = edge->spec.shortName, - .version = edge->spec.version, - .features = edge->spec.features, - .patches = patches, - }); - if (edited) { - std::ofstream out(manifestPath, std::ios::binary | std::ios::trunc); - out << *edited; - recorded = bool(out); - } - } - fs::remove_all(work, ec); - fs::remove_all(base, ec); - mcpp::ui::info("Saved", std::format("{} ({} bytes)", rel.generic_string(), diff.size())); - if (recorded) { - mcpp::ui::line(std::format(" mcpp.toml: {} patches = [{} file(s)]", edge->key, patches.size())); - } else { - std::string list; - for (auto const& p : patches) list += std::format("{}\"{}\"", list.empty() ? "" : ", ", p); - mcpp::ui::line(std::format(" add it to the edge in mcpp.toml: patches = [{}]", list)); - } - return 0; -} - -} // namespace mcpp::cli diff --git a/src/cli/cmd_patch.cppm b/src/cli/cmd_patch.cppm deleted file mode 100644 index 0c902f0ed..000000000 --- a/src/cli/cmd_patch.cppm +++ /dev/null @@ -1,20 +0,0 @@ -// mcpp.cli.cmd_patch — `mcpp patch edit ` and `mcpp patch save -// ` (mcpp#911 G8; docs/05 §"Patching a dependency"). -// -// `edit` copies the dependency's package root as this build reads it (its -// existing patches applied) to `target/patch//`, beside a copy of -// the same tree it is compared with. `save` writes the difference as the next -// patch of the edge, under `patches/`, named from the package's root, and adds -// it to the edge's `patches`; the two copies are then removed. - -export module mcpp.cli.cmd_patch; - -import std; -import mcpplibs.cmdline; - -export namespace mcpp::cli { - -int cmd_patch_edit(const mcpplibs::cmdline::ParsedArgs& parsed); -int cmd_patch_save(const mcpplibs::cmdline::ParsedArgs& parsed); - -} // namespace mcpp::cli diff --git a/src/cli/cmd_self.cppm b/src/cli/cmd_self.cppm index 003df8910..cbc8e4120 100644 --- a/src/cli/cmd_self.cppm +++ b/src/cli/cmd_self.cppm @@ -127,7 +127,7 @@ export int cmd_why(const mcpplibs::cmdline::ParsedArgs& parsed) { if (topic == "build-programs" || topic == "compat") return mcpp::doctor::why_build_programs_or_compat_json( topic, parsed.option_or_empty("features").value()); - if (topic == "target" || topic == "flags") + if (topic == "target" || topic == "flags" || topic == "platform") return mcpp::doctor::why_target_or_flags_json( topic, parsed.option_or_empty("target").value(), parsed.positional(1), parsed.option_or_empty("features").value()); diff --git a/src/doctor.cppm b/src/doctor.cppm index eed48dc9d..27b6ed4eb 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -1524,7 +1524,40 @@ nlohmann::json target_selection_json(const mcpp::build::BuildContext& ctx) { {"candidates", candidates} }; } -// `mcpp why target|flags --format json`. +// THE PLATFORM AT HAND (SPEC-016 §5): the root package's own statement when it +// is a platform package, else the platform the build uses for its target. +struct PlatformAtHand { + std::string name, version; + const mcpp::manifest::platform::Statement* statement = nullptr; +}; + +PlatformAtHand platform_at_hand(const mcpp::build::BuildContext& ctx) { + if (ctx.manifest.platformDeclared) + return { ctx.manifest.package.name, ctx.manifest.package.version, &ctx.manifest.platform }; + if (ctx.plan.platform.active) { + auto const& p = ctx.plan.platform; + auto dot = p.package.rfind('.'); + return { dot == std::string::npos ? p.package : p.package.substr(dot + 1), p.version, &p.fields }; + } + return {}; +} + +nlohmann::json platform_json(const mcpp::build::BuildContext& ctx) { + namespace mp = mcpp::manifest::platform; + const auto at = platform_at_hand(ctx); + if (!at.statement) return nullptr; + const auto name = mp::platform_name(at.name); + auto rows = nlohmann::json::array(); + for (auto const& d : at.statement->defines) rows.push_back(d.triple); + auto served = nlohmann::json::array(); + for (auto const& s : mp::served_identities(*at.statement, name)) + served.push_back({ {"identity", s.identity}, {"provider", s.provider}, {"priority", s.priority} }); + const auto f = mp::check_statement(*at.statement, name); + return { {"package", at.name}, {"version", at.version}, {"defines", rows}, {"serves", served}, + {"warnings", f.warnings} }; +} + +// `mcpp why target|flags|platform --format json`. export int why_target_or_flags_json(std::string_view topic, std::string_view target, std::string_view unit, const std::string& features) { const bool wasQuiet = mcpp::ui::is_quiet(); @@ -1545,9 +1578,10 @@ export int why_target_or_flags_json(std::string_view topic, std::string_view tar diags.push_back({.code = "prepare.failed", .severity = mcpp::wire::Severity::Error, .message = ctx.error()}); else if (topic == "target") data["target"] = target_selection_json(*ctx); + else if (topic == "platform") data["platform"] = platform_json(*ctx); else data["flags"] = mcpp::build::flag_origins_json(mcpp::build::flag_origins(ctx->plan, unit)); mcpp::wire::emit({ - .kind = topic == "target" ? "mcpp.why.target" : "mcpp.why.flags", + .kind = std::format("mcpp.why.{}", topic), .effects = { mcpp::wire::Effect::ReadProject }, .data = data, .diagnostics = diags, @@ -1588,12 +1622,12 @@ export int why_report(const std::string& topic, const std::string& features, const bool all = topic.empty() || topic == "all"; static constexpr std::string_view kTopics[] = { "all", "target", "toolchain", "sysroot", "runtime", "deps", "runners", "sources", "tool", - "payload", "build-programs", "compat", "flags", + "payload", "build-programs", "compat", "flags", "platform", }; if (!topic.empty() && std::ranges::find(kTopics, std::string_view(topic)) == std::end(kTopics)) { std::println(libc::stderr(), "error: '{}' is not a topic of `mcpp why`; the topics are " "target, toolchain, sysroot, runtime, deps, runners, sources, tool , " - "payload , build-programs, compat and flags []", topic); + "payload , build-programs, compat, flags [] and platform", topic); return 2; } @@ -1634,6 +1668,24 @@ export int why_report(const std::string& topic, const std::string& features, } } } + // THE PLATFORM'S ROWS AND THE IDENTITIES IT SERVES (SPEC-016 §5), each with + // the provider and its priority. + if (topic == "platform") { + namespace mp = mcpp::manifest::platform; + const auto at = platform_at_hand(*ctx); + if (!at.statement) { + std::println("platform: none (the build of {} uses no platform package)", ctx->targetSelection.identity); + return 0; + } + const auto name = mp::platform_name(at.name); + std::println("platform: {} {}", at.name, at.version); + for (auto const& d : at.statement->defines) std::println(" defines {}", d.triple); + for (auto const& s : mp::served_identities(*at.statement, name)) + std::println(" serves {:<28} {:<20} priority {}", s.identity, s.provider, s.priority); + for (auto const& w : mp::check_statement(*at.statement, name).warnings) + std::println(" warning: {}", w); + return 0; + } // EVERY WORD OF A UNIT'S COMMAND LINE AND WHO GAVE IT (mcpp#911 G2). if (topic == "flags") { const auto o = mcpp::build::flag_origins(ctx->plan, subject); @@ -1822,6 +1874,8 @@ export int why_report(const std::string& topic, const std::string& features, list += (list.empty() ? "" : ", ") + x.get(); std::println(" instances: {}", list); } + if (!entry.value("root", false) && entry.contains("manifest_dir")) + std::println(" root: {}", entry.value("manifest_dir", std::string{})); // A package with no target instance is linked into nothing here. const auto inst = entry.value("instances", nlohmann::json::array()); const bool linked = !inst.is_array() || inst.empty() diff --git a/src/pm/patch.cppm b/src/pm/patch.cppm index a78a7358e..e438aa3ef 100644 --- a/src/pm/patch.cppm +++ b/src/pm/patch.cppm @@ -50,12 +50,6 @@ std::expected materialize(const std::filesystem::path& source const std::filesystem::path& patchRoot = {}, std::vector* notes = nullptr); -// THE DIFFERENCE OF TWO TREES AS A PATCH (`mcpp patch save`): a unified diff of -// every file that differs, is added or is removed under `after` against -// `before`, the paths relative to the trees (`a/`, `b/`), three -// lines of context. Empty when the trees are the same. -std::string diff_trees(const std::filesystem::path& before, const std::filesystem::path& after); - // The directory name of a variant of `version`: `+p`. Short, because Windows paths have a length. std::string variant_dir_name(std::string_view version, std::string_view digest); @@ -386,132 +380,4 @@ std::expected materialize(const fs::path& source, return {}; } -namespace { - -// The lines of a file, without their newlines, and whether the last had one. -struct text_lines { - std::vector lines; - bool lastNewline = true; -}; - -text_lines lines_of(const std::string& bytes) { - text_lines t; - std::size_t at = 0; - while (at < bytes.size()) { - const auto nl = bytes.find('\n', at); - if (nl == std::string::npos) { - t.lines.push_back(bytes.substr(at)); - t.lastNewline = false; - break; - } - t.lines.push_back(bytes.substr(at, nl - at)); - at = nl + 1; - } - return t; -} - -// The longest common subsequence of `a` and `b` as pairs of indices, by the -// table of suffix lengths: files a patch edits are small. -std::vector> common(const std::vector& a, - const std::vector& b) { - const std::size_t n = a.size(), m = b.size(); - std::vector len((n + 1) * (m + 1), 0); - auto at = [&](std::size_t i, std::size_t j) -> std::uint32_t& { return len[i * (m + 1) + j]; }; - for (std::size_t i = n; i-- > 0;) - for (std::size_t j = m; j-- > 0;) - at(i, j) = a[i] == b[j] ? at(i + 1, j + 1) + 1 : std::max(at(i + 1, j), at(i, j + 1)); - std::vector> out; - std::size_t i = 0, j = 0; - while (i < n && j < m) { - if (a[i] == b[j]) { out.emplace_back(i++, j++); continue; } - if (at(i + 1, j) >= at(i, j + 1)) ++i; else ++j; - } - return out; -} - -// One file's unified diff; `rel` its path, empty `before`/`after` path for an -// added or removed file. -std::string diff_file(const std::string& rel, const std::optional& before, - const std::optional& after) { - const auto a = lines_of(before.value_or(std::string{})); - const auto b = lines_of(after.value_or(std::string{})); - // The edit script: each entry is (kind, line of a, line of b); ' ', '-', '+'. - struct op { char kind; std::size_t ai, bi; }; - std::vector script; - std::size_t i = 0, j = 0; - for (auto [ci, cj] : common(a.lines, b.lines)) { - while (i < ci) script.push_back({'-', i++, j}); - while (j < cj) script.push_back({'+', i, j++}); - script.push_back({' ', i++, j++}); - } - while (i < a.lines.size()) script.push_back({'-', i++, j}); - while (j < b.lines.size()) script.push_back({'+', i, j++}); - std::string out = std::format("--- {}\n+++ {}\n", before ? "a/" + rel : std::string("/dev/null"), - after ? "b/" + rel : std::string("/dev/null")); - constexpr std::size_t kContext = 3; - std::size_t k = 0; - while (k < script.size()) { - if (script[k].kind == ' ') { ++k; continue; } - // A hunk: from the change back by the context, forward while changes - // come within twice the context of each other. - std::size_t begin = k >= kContext ? k - kContext : 0; - while (begin < k && script[begin].kind != ' ') ++begin; - std::size_t end = k, quiet = 0; - for (std::size_t q = k; q < script.size(); ++q) { - if (script[q].kind == ' ') { if (++quiet > 2 * kContext) break; } - else { quiet = 0; end = q; } - } - const std::size_t stop = std::min(script.size(), end + 1 + kContext); - std::size_t oldCount = 0, newCount = 0; - for (std::size_t q = begin; q < stop; ++q) { - if (script[q].kind != '+') ++oldCount; - if (script[q].kind != '-') ++newCount; - } - const std::size_t oldStart = oldCount ? script[begin].ai + 1 : script[begin].ai; - const std::size_t newStart = newCount ? script[begin].bi + 1 : script[begin].bi; - out += std::format("@@ -{},{} +{},{} @@\n", oldStart, oldCount, newStart, newCount); - for (std::size_t q = begin; q < stop; ++q) { - const auto& o = script[q]; - const bool fromA = o.kind != '+'; - const auto& text = fromA ? a.lines[o.ai] : b.lines[o.bi]; - out += o.kind; - out += text; - out += '\n'; - const bool last = fromA ? o.ai + 1 == a.lines.size() && !a.lastNewline - : o.bi + 1 == b.lines.size() && !b.lastNewline; - if (last) out += "\\ No newline at end of file\n"; - } - k = stop; - } - return out; -} - -std::set files_under(const fs::path& root) { - std::set out; - std::error_code ec; - if (!fs::is_directory(root, ec)) return out; - for (auto it = fs::recursive_directory_iterator(root, ec); !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { - if (!it->is_regular_file(ec)) continue; - auto rel = fs::relative(it->path(), root, ec).generic_string(); - if (rel == ".mcpp-variant" || rel.starts_with(".mcpp-patch")) continue; - out.insert(std::move(rel)); - } - return out; -} - -} // namespace - -std::string diff_trees(const fs::path& before, const fs::path& after) { - auto all = files_under(before); - all.merge(files_under(after)); - std::string out; - for (auto const& rel : all) { - auto a = read_bytes(before / rel); - auto b = read_bytes(after / rel); - if (a && b && *a == *b) continue; - out += diff_file(rel, a, b); - } - return out; -} - } // namespace mcpp::pm::patch diff --git a/src/scaffold/create.cppm b/src/scaffold/create.cppm index 6868ef878..016f0527e 100644 --- a/src/scaffold/create.cppm +++ b/src/scaffold/create.cppm @@ -304,7 +304,7 @@ export enum class builtin { bin, gui, lib }; // `mcpp new --template platform` (SPEC-016 §2, §3): a platform package that // defines one row, `-`, and the provider that serves it, with the -// llvm the built-in bare-metal rows pin. `mcpp check platform` reads it. +// llvm the built-in bare-metal rows pin. `mcpp why platform` lists what it serves. export int create_platform_project(const mcpp::scaffold::PortableProjectName& project); // What a project's library is named in C++: its module, the qualified name @@ -644,7 +644,7 @@ driver = "compiler" } mcpp::ui::line(std::format("Created platform package '{}' at {}, defining {}", project.qualifiedName, tx.final_path().string(), triple)); - mcpp::ui::line(std::format("Next: cd {} && mcpp check platform", project.directoryName)); + mcpp::ui::line(std::format("Next: cd {} && mcpp why platform", project.directoryName)); return 0; } diff --git a/tests/e2e/1038_test_programs_of_a_directory_and_the_tests_a_change_affects.sh b/tests/e2e/1038_test_programs_of_a_directory_and_the_tests_a_change_affects.sh index 6e48b757d..1c084387d 100644 --- a/tests/e2e/1038_test_programs_of_a_directory_and_the_tests_a_change_affects.sh +++ b/tests/e2e/1038_test_programs_of_a_directory_and_the_tests_a_change_affects.sh @@ -5,7 +5,8 @@ # # Criteria: # A. `[test] programs = "directory"`: the files of a directory below `tests/` are one program, -# named by the directory, whose cases are all of theirs; a directory whose file defines `main` +# named by the directory, whose cases are all of theirs, run as a process per file of its cases +# and, under `split = "none"`, once; a directory whose file defines `main` # begins there; two files of one directory that define `main` are refused, naming both. # B. A program built from a file of plain `main` that links a framework's library (it carries the # protocol's marker and speaks none) is one case, judged by its exit status, run and listed. @@ -50,6 +51,11 @@ printf 'data\n' > tests/data/f.txt # A printf '\n[test]\nprograms = "directory"\n' >> mcpp.toml "$MCPP" test > a.log 2>&1 || fail "A: the directory programs" a.log +# A program that speaks the protocol runs as a process per file of its cases (`split = "file"`). +grep -q 'unit/a \.\.\. ok (.*1 case)' a.log && grep -q 'unit/b \.\.\. ok (.*1 case)' a.log \ + || fail "A: unit does not run as a process per file" a.log +printf 'split = "none"\n' >> mcpp.toml +"$MCPP" test > a.log 2>&1 || fail "A: the directory programs, run once" a.log grep -q 'unit \.\.\. ok (.*2 cases)' a.log || fail "A: unit is not one program of two cases" a.log grep -q '^e2e \.\.\. ok' a.log || fail "A: the directory of a file with main" a.log "$MCPP" test --cases > a-cases.log 2>&1 || fail "A: --cases" a-cases.log diff --git a/tests/e2e/1046_a_dependency_is_edited_and_its_change_saved_as_a_patch.sh b/tests/e2e/1046_a_dependency_is_edited_and_its_change_saved_as_a_patch.sh deleted file mode 100755 index 065bcb400..000000000 --- a/tests/e2e/1046_a_dependency_is_edited_and_its_change_saved_as_a_patch.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -# requires: elf gcc -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1046 -- a dependency is edited and its change saved as its patch (mcpp#911 G8; docs/05). -# -# `mcpp patch edit ` copies the dependency's package root, as the build reads it, to -# target/patch//. `mcpp patch save ` writes the difference as the edge's -# next patch under patches/, named from the package's root, and adds it to the edge; the build -# then reads the patched variant. -# -# Criteria: the patch names the edited file from the package's root; mcpp.toml's edge lists it; -# the build succeeds and its variant tree holds the change; a second save without a change writes -# nothing. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -mkdir -p "$t/src" -cat > "$t/mcpp.toml" <<'EOF' -[package] -name = "app" -version = "0.1.0" - -[dependencies] -mcpplibs.cmdline = "0.0.1" -EOF -echo 'int main() { return 0; }' > "$t/src/main.cpp" -cd "$t" - -out=$("$MCPP" patch edit cmdline 2>&1) || fail "patch edit failed" "$out" -grep -q "Editing cmdline at .*target/patch/mcpplibs.cmdline" <<<"$out" || fail "the edit tree is not reported" "$out" -file=$(find target/patch/mcpplibs.cmdline -name parse.cppm | head -1) -[ -n "$file" ] || fail "the edit tree holds no parse.cppm" "$(find target/patch -maxdepth 3)" -rel=${file#target/patch/mcpplibs.cmdline/} -echo '// changed through mcpp patch' >> "$file" - -out=$("$MCPP" patch save cmdline 2>&1) || fail "patch save failed" "$out" -grep -q "Saved patches/cmdline-0001.patch" <<<"$out" || fail "the patch is not saved" "$out" -grep -q -- "--- a/$rel" patches/cmdline-0001.patch || fail "the patch does not name the file from the package root" "$(head -3 patches/cmdline-0001.patch)" -grep -q 'patches = \["patches/cmdline-0001.patch"\]' mcpp.toml || fail "the edge does not list the patch" "$(cat mcpp.toml)" -[ ! -e target/patch/mcpplibs.cmdline ] || fail "the edit tree is left behind" -echo " ok the change is saved as the edge's patch, named from the package root" - -out=$("$MCPP" build 2>&1) || fail "the patched build failed" "$out" -home="${MCPP_HOME:-$HOME/.mcpp}" -grep -rqs 'changed through mcpp patch' "$home"/cache/src/mcpplibs.cmdline/*/"$rel" || fail "no variant holds the change" -echo " ok the build reads the patched variant" - -"$MCPP" patch edit cmdline >/dev/null 2>&1 || fail "the second edit failed" -out=$("$MCPP" patch save cmdline 2>&1) || fail "the second save failed" "$out" -grep -q "Unchanged" <<<"$out" || fail "a save without a change wrote something" "$out" -[ ! -e patches/cmdline-0002.patch ] || fail "a second patch was written" -echo " ok a save without a change writes nothing" -echo "PASS: 1046 a dependency is edited and its change saved as a patch" diff --git a/tests/e2e/1046_a_patch_made_with_git_diff_from_the_package_root_is_applied.sh b/tests/e2e/1046_a_patch_made_with_git_diff_from_the_package_root_is_applied.sh new file mode 100755 index 000000000..019f5a1b1 --- /dev/null +++ b/tests/e2e/1046_a_patch_made_with_git_diff_from_the_package_root_is_applied.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# requires: elf gcc +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1046 -- a patch made with `git diff` from the package's root is applied (mcpp#911 G8; docs/05). +# +# A patch names files from the package's root, the directory of its resolved manifest, which +# `mcpp why deps` reports. A copy of that tree under git, edited, gives the patch with +# `git diff`; the edge's `patches` lists it, and the build reads the patched variant, whose root +# `mcpp why deps` then reports. +# +# Criteria: `mcpp why deps` reports the dependency's root; the patch git writes from a copy of it +# applies; the variant holds the change and is the root reported afterwards. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +mkdir -p "$t/app/src" +cd "$t/app" +cat > mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[dependencies] +mcpplibs.cmdline = "0.0.1" +EOF +echo 'int main() { return 0; }' > src/main.cpp + +out=$("$MCPP" why deps 2>&1) || fail "why deps failed" "$out" +root=$(sed -n '/^ mcpplibs.cmdline[@+]/,/^ [^ ]/s/^ root: //p' <<<"$out" | head -1) +[ -n "$root" ] && [ -d "$root" ] || fail "the dependency's root is not reported" "$out" +echo " ok why deps reports the root" + +cp -R "$root" "$t/cmdline" +file=$(cd "$t/cmdline" && find . -name parse.cppm | head -1); file=${file#./} +[ -n "$file" ] || fail "the root holds no parse.cppm" "$(ls -R "$t/cmdline" | head)" +(cd "$t/cmdline" && git init -q && git add -A && git -c user.name=t -c user.email=t@t commit -qm base \ + && echo '// changed for the patch' >> "$file") +mkdir -p patches +(cd "$t/cmdline" && git diff) > patches/0001-change.patch +grep -q -- "--- a/$file" patches/0001-change.patch || fail "git did not name the file from the root" "$(head -3 patches/0001-change.patch)" +sed -i.bak 's|^mcpplibs.cmdline = "0.0.1"$|mcpplibs.cmdline = { version = "0.0.1", patches = ["patches/0001-change.patch"] }|' mcpp.toml + +out=$("$MCPP" build 2>&1) || fail "the patched build failed" "$out" +out=$("$MCPP" why deps 2>&1) || fail "why deps failed after the patch" "$out" +variant=$(sed -n '/^ mcpplibs.cmdline[@+]/,/^ [^ ]/s/^ root: //p' <<<"$out" | head -1) +[ -n "$variant" ] && [ "$variant" != "$root" ] || fail "the variant is not the reported root" "$out" +grep -q 'changed for the patch' "$variant/$file" || fail "the variant does not hold the change" +echo " ok the patch applies and the variant is the root the build reads" +echo "PASS: 1046 a patch made with git diff from the package root is applied" diff --git a/tests/e2e/1049_a_platform_skeleton_is_made_and_checked.sh b/tests/e2e/1049_a_platform_skeleton_is_made_and_checked.sh deleted file mode 100755 index ed6cff1d6..000000000 --- a/tests/e2e/1049_a_platform_skeleton_is_made_and_checked.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash -# requires: elf llvm -source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" -# 1049 -- a platform skeleton is made and checked (SPEC-016 §2, §3). -# -# `mcpp new --template platform` writes a platform package that defines `-` and -# the provider that serves it; `mcpp check platform` reads it as the engine does and lists the -# identities it serves. A row that names a built-in row is refused, and a provider that serves -# nothing is said. -# -# Criteria: the skeleton checks clean and serves x86_64- (on x86_64); a program builds for -# the row; a redefined built-in row ends the check with 1; a provider serving nothing is a warning. -set -e -t=$(mktemp -d); trap 'rm -rf "$t"' EXIT -fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } -cd "$t" -arch=$(uname -m); [ "$arch" = arm64 ] && arch=aarch64 -out=$("$MCPP" new myos --template platform 2>&1) || fail "the skeleton was not made" "$out" -out=$(cd myos && "$MCPP" check platform 2>&1) || fail "the skeleton does not check" "$out" -grep -Eq "serves $arch-myos +myos +priority 100" <<<"$out" || fail "the check does not list the served row" "$out" -echo " ok the skeleton checks and serves $arch-myos" - -mkdir -p app/src -cat > app/mcpp.toml < app/src/main.c -out=$(cd app && "$MCPP" build --target "$arch-myos" 2>&1) || fail "a program did not build for the row" "$out" -echo " ok a program builds for the platform's row" - -cat >> myos/mcpp.toml <<'EOF' - -[[platform.define]] -triple = "x86_64-linux-gnu" - -[[platform.provider]] -name = "nothing" -serves = ["riscv64-nosuch"] -EOF -set +e; out=$(cd myos && "$MCPP" check platform 2>&1); rc=$?; set -e -[ "$rc" = 1 ] || fail "a redefined built-in row did not fail the check (exit $rc)" "$out" -grep -q "'x86_64-linux-gnu' is a built-in row" <<<"$out" || fail "the redefinition is not named" "$out" -grep -q "the provider 'nothing' serves no row" <<<"$out" || fail "a provider serving nothing is not said" "$out" -echo " ok a redefined built-in row fails the check; a provider serving nothing is said" -echo "PASS: 1049 a platform skeleton is made and checked" diff --git a/tests/e2e/1049_a_platform_skeleton_is_made_and_its_statement_read_as_the_engine_reads_it.sh b/tests/e2e/1049_a_platform_skeleton_is_made_and_its_statement_read_as_the_engine_reads_it.sh new file mode 100755 index 000000000..7594296b7 --- /dev/null +++ b/tests/e2e/1049_a_platform_skeleton_is_made_and_its_statement_read_as_the_engine_reads_it.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# requires: elf llvm +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1049 -- a platform skeleton is made, and its statement is read as the engine reads it +# (SPEC-016 §5). +# +# `mcpp new --template platform` writes a platform package that defines `-` and +# the provider that serves it; `mcpp why platform` lists the identities it serves. Reading the +# platform's manifest checks the statement against the rows: a definition of a built-in row is an +# error wherever the platform is read, and a provider that serves no row is a warning of the +# platform's own build. +# +# Criteria: `mcpp why platform` lists - with its provider and priority, in the platform +# and in a project that selects it; a program builds for the row; a provider serving nothing is a +# warning of the platform's build; a redefined built-in row fails the project's build and names it. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +cd "$t" +arch=$(uname -m); [ "$arch" = arm64 ] && arch=aarch64 +out=$("$MCPP" new myos --template platform 2>&1) || fail "the skeleton was not made" "$out" +grep -q "mcpp why platform" <<<"$out" || fail "the next step is not named" "$out" +out=$(cd myos && "$MCPP" why platform 2>&1) || fail "the skeleton is not read" "$out" +grep -Eq "serves +$arch-myos +myos +priority 100" <<<"$out" || fail "the served row is not listed" "$out" +echo " ok the skeleton serves $arch-myos" + +mkdir -p app/src +cat > app/mcpp.toml < app/src/main.c +out=$(cd app && "$MCPP" build --target "$arch-myos" 2>&1) || fail "a program did not build for the row" "$out" +out=$(cd app && "$MCPP" why platform --target "$arch-myos" 2>&1) || fail "the selected platform is not read" "$out" +grep -Eq "serves +$arch-myos +myos +priority 100" <<<"$out" || fail "the project's platform is not listed" "$out" +out=$(cd app && "$MCPP" why platform --target "$arch-myos" --format json 2>&1) || fail "no JSON" "$out" +grep -Eq '"identity": *"'"$arch"'-myos"' <<<"$out" || fail "the JSON lacks the served identity" "$out" +echo " ok a program builds for the row; the project reports the platform it selects" + +cat >> myos/mcpp.toml <<'EOF' + +[[platform.provider]] +name = "nothing" +serves = ["riscv64-nosuch"] +EOF +out=$(cd myos && "$MCPP" build 2>&1) || fail "a warning ended the platform's build" "$out" +grep -q "\[\[platform.provider\]\] 'nothing' serves no row" <<<"$out" || fail "a provider serving nothing is not said" "$out" + +cat >> myos/mcpp.toml <<'EOF' + +[[platform.define]] +triple = "x86_64-linux-gnu" +EOF +set +e; out=$(cd app && "$MCPP" build --target "$arch-myos" 2>&1); rc=$?; set -e +[ "$rc" != 0 ] || fail "a redefined built-in row did not fail the build" "$out" +grep -q "'x86_64-linux-gnu' is a built-in row" <<<"$out" || fail "the redefinition is not named" "$out" +echo " ok a provider serving nothing is a warning; a redefined built-in row fails the build" +echo "PASS: 1049 a platform skeleton is made and its statement read as the engine reads it" diff --git a/tests/e2e/1050_a_test_run_is_kept_in_the_mtest_store.sh b/tests/e2e/1050_a_test_run_is_kept_in_the_mtest_store.sh new file mode 100755 index 000000000..a9a1f9d4f --- /dev/null +++ b/tests/e2e/1050_a_test_run_is_kept_in_the_mtest_store.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# requires: gcc python3 +# 1050 -- a test run is kept in the mtest store's form (docs/08; mtest spec P12). +# +# `mcpp test` writes `target/mtest/runs//` -- `run.json`, a line per process in +# `programs.ndjson`, a line per case in `cases.ndjson` -- and appends the run to +# `target/mtest/index.ndjson`. `[test] store` names another directory; `"none"` keeps nothing. +# +# Criteria: a run is one record of the index with its counts; each process states its status, +# duration, start and worker; each case its program and status; a second run is a second record; +# `store = "none"` adds none; `store = ""` writes there. +set -e +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +PY=python3; command -v python3 >/dev/null 2>&1 || PY=python + +cd "$TMP" +"$MCPP" new prog > new.log 2>&1 || fail "mcpp new" new.log +cd prog +"$MCPP" test > ../t1.log 2>&1 || fail "the first run failed" ../t1.log +[ -f target/mtest/index.ndjson ] || fail "no index" ../t1.log +"$PY" - <<'EOF' || fail "the stored run is not as stated" target/mtest/index.ndjson +import json, pathlib +store = pathlib.Path("target/mtest") +runs = [json.loads(l) for l in (store / "index.ndjson").read_text().splitlines() if l.strip()] +assert len(runs) == 1, runs +run = runs[0] +assert run["schema"] == "mtest.run/1" and run["status"] == "pass", run +assert run["counts"].get("pass", 0) >= 1, run +d = store / "runs" / run["id"] +assert json.loads((d / "run.json").read_text())["id"] == run["id"] +programs = [json.loads(l) for l in (d / "programs.ndjson").read_text().splitlines() if l.strip()] +assert programs and all(p["status"] == "pass" and p["ms"] >= 0 and p["started_ms"] >= 0 + and p["worker"] >= 0 for p in programs), programs +cases = [json.loads(l) for l in (d / "cases.ndjson").read_text().splitlines() if l.strip()] +assert cases and all(c["status"] == "pass" and c["program"] for c in cases), cases +EOF +echo " ok a run is kept with its processes and cases" + +"$MCPP" test > ../t2.log 2>&1 || fail "the second run failed" ../t2.log +[ "$(wc -l < target/mtest/index.ndjson | tr -d ' ')" = 2 ] || fail "a second run is not a second record" target/mtest/index.ndjson +printf '\n[test]\nstore = "none"\n' >> mcpp.toml +"$MCPP" test > ../t3.log 2>&1 || fail "the third run failed" ../t3.log +[ "$(wc -l < target/mtest/index.ndjson | tr -d ' ')" = 2 ] || fail "store = none kept a run" target/mtest/index.ndjson +sed -i.bak 's|^store = "none"$|store = "out/test-runs"|' mcpp.toml +"$MCPP" test > ../t4.log 2>&1 || fail "the fourth run failed" ../t4.log +[ -f out/test-runs/index.ndjson ] || fail "store = did not write there" ../t4.log +echo " ok each run is a record; store = none keeps nothing; store = writes there" +echo "PASS: 1050 a test run is kept in the mtest store" diff --git a/tests/unit/test_patch.cpp b/tests/unit/test_patch.cpp index 8c0b3ac0a..dd965475e 100644 --- a/tests/unit/test_patch.cpp +++ b/tests/unit/test_patch.cpp @@ -183,36 +183,3 @@ UTest a_patch_names_files_from_the_package_root{"Patch/APatchNamesFilesFromThePa expect(ne(notes[0].find("from the archive's top"), std::string::npos)) << notes[0]; fs::remove_all(base); }}; - -// `mcpp patch save`: the difference of two trees is a patch that turns the -// first into the second -- changed, added and removed files, and a last line -// without a newline. -UTest the_difference_of_two_trees_is_a_patch_that_makes_one_the_other{"Patch/TheDifferenceOfTwoTreesIsAPatchThatMakesOneTheOther", [] { - auto base = fresh("diff"); - std::string longText; - for (int i = 1; i <= 30; ++i) longText += std::format("line {}\n", i); - put(base / "before/src/a.c", longText); - put(base / "before/gone.txt", "bye\n"); - put(base / "before/tail.txt", "no newline"); - std::string changed = longText; - changed.replace(changed.find("line 3\n"), 7, "line three\n"); - changed.replace(changed.find("line 25\n"), 8, ""); - changed += "line 31\n"; - put(base / "after/src/a.c", changed); - put(base / "after/new/b.h", "#pragma once\n"); - put(base / "after/tail.txt", "no newline, still"); - const auto diff = pt::diff_trees(base / "before", base / "after"); - expect(ne(diff.find("--- a/src/a.c\n+++ b/src/a.c\n"), std::string::npos)) << diff; - expect(ne(diff.find("--- /dev/null\n+++ b/new/b.h\n"), std::string::npos)) << diff; - expect(ne(diff.find("--- a/gone.txt\n+++ /dev/null\n"), std::string::npos)) << diff; - // Applied to a copy of the first tree, it gives the second. - fs::copy(base / "before", base / "check", fs::copy_options::recursive); - auto r = pt::apply(base / "check", diff, "saved.patch"); - if (auto&& failed = expect(fatal(r.has_value())); !failed) failed << r.error() << "\n" << diff; - expect(eq(get(base / "check/src/a.c"), changed)); - expect(eq(get(base / "check/new/b.h"), "#pragma once\n")); - expect(eq(get(base / "check/tail.txt"), "no newline, still")); - expect(!fs::exists(base / "check/gone.txt")); - expect(pt::diff_trees(base / "after", base / "check").empty()); - fs::remove_all(base); -}}; diff --git a/tests/unit/test_platform.cpp b/tests/unit/test_platform.cpp index 828edbc0d..a1cefa43f 100644 --- a/tests/unit/test_platform.cpp +++ b/tests/unit/test_platform.cpp @@ -595,3 +595,65 @@ UTest a_repository_reference_has_a_string_form{"PlatformManifest/ARepositoryRefe expect(!mp::parse_ref_text("git+https://example.org/x.git#sha=1").has_value()); expect(!mp::parse_ref_text("git+").has_value()); }}; + +// A PLATFORM'S STATEMENT IS CHECKED AGAINST THE ROWS AS IT IS READ (SPEC-016 §5): +// a definition of a built-in row and a row defined twice are errors; a provider +// that serves no row and a tie of priorities are warnings; the served +// identities are ordered by identity, then priority, then provider. +UTest a_platform_statement_is_checked_against_the_rows{"PlatformManifest/AStatementIsCheckedAgainstTheRows", [] { + constexpr std::string_view kHead = R"( +[package] +name = "toy-platform" +version = "0.1.0" + +[platform] +toolchain = "llvm@23.1.3" +)"; + auto redefined = parse_error(std::string(kHead) + R"( +[[platform.define]] +triple = "x86_64-linux-gnu" + +[[platform.provider]] +serves = ["x86_64-linux-gnu"] +)"); + if (auto&& failed = expect(ne(redefined.find("'x86_64-linux-gnu' is a built-in row"), std::string::npos)); !failed) + failed << redefined; + auto twice = parse_error(std::string(kHead) + R"( +[[platform.define]] +triple = "x86_64-toy" + +[[platform.define]] +triple = "x86_64-toy" + +[[platform.provider]] +serves = ["x86_64-toy"] +)"); + if (auto&& failed = expect(ne(twice.find("'x86_64-toy' is defined twice"), std::string::npos)); !failed) + failed << twice; + + auto m = parse(std::string(kHead) + R"( +[[platform.define]] +triple = "x86_64-toy" + +[[platform.provider]] +serves = ["x86_64-toy"] + +[[platform.provider]] +name = "second" +serves = ["*-toy"] + +[[platform.provider]] +name = "nothing" +serves = ["riscv64-nosuch"] +)"); + auto has = [&](std::string_view s) { + return std::ranges::any_of(m.schemaWarnings, [&](auto const& w) { return w.find(s) != std::string::npos; }); + }; + expect(has("'nothing' serves no row")); + expect(has("'x86_64-toy' is served by 'second' and 'toy' at priority 100")); + auto served = mp::served_identities(m.platform, "toy"); + expect(fatal(eq(served.size(), std::size_t{2}))); + expect(eq(served[0].identity, "x86_64-toy")); + expect(eq(served[0].provider, "second")); + expect(eq(served[1].provider, "toy")); +}}; From cd186698b077de272dcc9518492334c4494af996 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 12:20:13 +0900 Subject: [PATCH 27/45] test: an archive's tests run by mcpp test's own runner; the bootstrap knows [test] programs - `mcpp test --archive` composes its tests as runnables and runs them through the runner a build's tests use: the protocol each implements (recorded in mcpp-tests.json as the build decided it), a process per file of its cases, the workers, the mtest store. The store is written for `--case` runs too. - A program of one file is not listed to be split by file. - The store records GITHUB_SHA as the run's commit; docs/08 states how mtest takes the store in (`mtest store import`, or `[project] store`). - The bootstrap is mcpp 2026.11.8.1, which reads `[test] programs`; the READMEs list the rows the tier check found missing. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .xlings.json | 2 +- CHANGELOG.md | 7 +- README.md | 3 +- README.zh-CN.md | 3 +- docs/08-testing.md | 22 +- docs/specs/target-sets.md | 13 +- docs/zh/08-testing.md | 14 +- src/build/execute.cppm | 213 ++++++++++-------- ...0_a_test_run_is_kept_in_the_mtest_store.sh | 8 +- 9 files changed, 163 insertions(+), 122 deletions(-) diff --git a/.xlings.json b/.xlings.json index f3a65ae90..e044bb304 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": "2026.11.5.1" + "mcpp": "2026.11.8.1" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c71610e..b7dfded50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,8 +55,11 @@ and tests link with `--gc-sections` (`[test] link = "objects"` keeps every object whole); a protocol marker nothing references is read from the objects the test was linked from. A run is kept in the mtest store's form under - `target/mtest` (`[test] store`), which `mtest report`, `analyze` and `graph` - read. mcpp's own unit tests are one program per directory: 166 + `target/mtest` (`[test] store`), which `mtest store import` takes in for + `mtest report`, `analyze` and `graph`. The tests of an archive run by the + same runner (`mcpp test --archive`): their protocol, recorded in + `mcpp-tests.json`, a process per file of their cases, the workers and the + store. mcpp's own unit tests are one program per directory: 166 programs of 32 MB became one of 37 MB, run as 166 processes. - clang pairs with the MinGW-w64 payload for `x86_64-windows-gnu`: the payload is clang's sysroot and libstdc++ its C++ library, `import std` included. diff --git a/README.md b/README.md index 9c3c8f01f..186b6a041 100644 --- a/README.md +++ b/README.md @@ -446,11 +446,12 @@ list` reports for this machine): | `x86_64-windows-msvc` | `msvc@system` (detected VS/BuildTools) or llvm ¹ *(Windows default with Visual Studio)* | verified | | `x86_64-windows-musl` | llvm 23 — a PE with a musl C library, which no gcc emits; the system comes from the dependency graph | preview | | `aarch64-macos` | llvm *(macOS default)* | verified | +| `aarch64-macos-musl` · `x86_64-macos-musl` | llvm 23 — a Mach-O with a musl C library from the dependency graph, built on any host *(what `aarch64-macos` is off macOS)* | preview | | `riscv64-none-elf` · `riscv32-none-elf` | llvm 23 — bare metal, `xim:picolibc-riscv` ² | verified | | `thumbv6m-none-eabi` · `thumbv7m-none-eabi` | llvm 23 — Cortex-M0/M0+/M1, Cortex-M3 ² | verified | | `thumbv7em-none-eabihf` · `thumbv8m.main-none-eabi` | llvm 23 — Cortex-M4F/M7F hard float, Cortex-M33/M55 soft float ² | verified | | `armv7a-none-eabi` · `armv7a-none-eabihf` | llvm 23 — Cortex-A 32-bit, the first row with an MMU ² | verified | -| `aarch64-none-elf` · `x86_64-none-elf` | llvm 23 — bare metal, no C library by default ² | preview | +| `aarch64-none-elf` · `x86_64-none-elf` · `i686-none-elf` | llvm 23 — bare metal, no C library by default ² | preview | | `thumbv7em-none-eabi` · `thumbv8m.base-none-eabi` · `thumbv8m.main-none-eabihf` | llvm 23 — Cortex-M4/M7 soft float, M23, M33F/M55F ² | preview | | `aarch64-linux-gnu` | `llvm@23.1.3` | preview | | `riscv64-linux-musl` · `x86_64-macos` | — | planned | diff --git a/README.zh-CN.md b/README.zh-CN.md index d2673588d..a336981eb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -395,11 +395,12 @@ mcpp 的身份模型有两条正交的轴:**工具链**是 `family@version`( | `x86_64-windows-msvc` | `msvc@system`(探测 VS/BuildTools)或 llvm ¹(*有 Visual Studio 时的 Windows 默认*) | verified | | `x86_64-windows-musl` | llvm 23;带 musl C 库的 PE,gcc 无法产出;系统部分由依赖图提供 | preview | | `aarch64-macos` | llvm(*macOS 默认*) | verified | +| `aarch64-macos-musl` · `x86_64-macos-musl` | llvm 23;带 musl C 库的 Mach-O,系统部分由依赖图提供,任意宿主可构建(*非 macOS 宿主上的 `aarch64-macos`*) | preview | | `riscv64-none-elf` · `riscv32-none-elf` | llvm 23;裸机,`xim:picolibc-riscv` ² | verified | | `thumbv6m-none-eabi` · `thumbv7m-none-eabi` | llvm 23;Cortex-M0/M0+/M1、Cortex-M3 ² | verified | | `thumbv7em-none-eabihf` · `thumbv8m.main-none-eabi` | llvm 23;Cortex-M4F/M7F 硬浮点,Cortex-M33/M55 软浮点 ² | verified | | `armv7a-none-eabi` · `armv7a-none-eabihf` | llvm 23;Cortex-A 32 位,第一个带 MMU 的目标 ² | verified | -| `aarch64-none-elf` · `x86_64-none-elf` | llvm 23;裸机,默认不带 C 库 ² | preview | +| `aarch64-none-elf` · `x86_64-none-elf` · `i686-none-elf` | llvm 23;裸机,默认不带 C 库 ² | preview | | `thumbv7em-none-eabi` · `thumbv8m.base-none-eabi` · `thumbv8m.main-none-eabihf` | llvm 23;Cortex-M4/M7 软浮点、M23、M33F/M55F ² | preview | | `aarch64-linux-gnu` | `llvm@23.1.3` | preview | | `riscv64-linux-musl` · `x86_64-macos` | — | planned | diff --git a/docs/08-testing.md b/docs/08-testing.md index d2e1fbd31..0a4c486e4 100644 --- a/docs/08-testing.md +++ b/docs/08-testing.md @@ -66,8 +66,8 @@ where a program per file is linked once per file, which is what a suite of many small files spends its build time on. A program that speaks the test protocol runs as **one process per file of its -cases** (2026.11.9.1+): mcpp lists the cases, and each file's run selects its -own (P4) and is scheduled across the workers as a program is, named +cases** (2026.11.9.1+): mcpp lists the cases of a program of several files, and +each file's run selects its own (P4) and is scheduled across the workers as a program is, named `/` -- the isolation and the parallelism of a program per file, with one link. `[test] split = "case"` gives each case its own process, and `"none"` runs the program once. @@ -82,10 +82,13 @@ object whole. A run is kept in the form of the mtest store (2026.11.9.1+): `target/mtest/` holds `runs//run.json`, a line per process in `programs.ndjson` (its status, duration, start, worker and selected cases) and a line per case in -`cases.ndjson`, and `index.ndjson` lists the runs. `mtest report`, `analyze` -and `graph --store target/mtest` read them as mtest's own runs: the report, the -slowest cases and the run's schedule. `[test] store = ""` names another -directory, relative to the project; `"none"` keeps nothing. +`cases.ndjson`, and `index.ndjson` lists the runs. `mtest store import --from +target/mtest` takes them into mtest's store, and an `mtest.toml` whose +`[project] store` is `target/mtest` reads them where they are; `mtest report`, +`analyze` and `graph` then read them as mtest's own runs (the Markdown report, +the slowest cases, the critical path and the schedule). In CI, `commit` and +`group` are `GITHUB_SHA` and `GITHUB_RUN_ID`. `[test] store = ""` names +another directory, relative to the project; `"none"` keeps nothing. A suite compiled from several sources of its own is also a package: a workspace member whose `[build] sources` carries the suite and whose one test program @@ -297,9 +300,10 @@ mcpp test --archive tests.tar.gz # in a checkout: ru ``` The archive holds each leg's test programs and `mcpp-tests.json`, which names -each test's leg, arguments and working directory. Reading it builds nothing; -the tests run in the checkout, through this machine's runner or system, and the -report is `mcpp test`'s. A failed test exits 1, and so does an archive of which +each test's leg, arguments, working directory and the test protocol it +implements. Reading it builds nothing; the tests run in the checkout, through +this machine's runner or system, by `mcpp test`'s own runner: its cases, a +process per file of them, the workers and the store (2026.11.9.1+). A failed test exits 1, and so does an archive of which no test ran. Legs this machine does not reach are reported as not run ([SPEC-015 §4](specs/target-sets.md)). The archive is a tar compressed as its name states: `.tar.gz` (or `.tgz`), `.tar.zst`, `.tar.xz` or `.tar`; another diff --git a/docs/specs/target-sets.md b/docs/specs/target-sets.md index eb1b31e78..15c25a337 100644 --- a/docs/specs/target-sets.md +++ b/docs/specs/target-sets.md @@ -4,9 +4,9 @@ |---|---| | 规范编号 | SPEC-015 | | 标题 | 目标集合与腿:集合的写法与展开,命令对集合的语义,腿的规划与构建,测试归档,机器输出 | -| 状态 | 评审中 v1.0 | -| 版本 | 1.0 | -| 最后修改 | 2026-10-30 | +| 状态 | 评审中 v1.1 | +| 版本 | 1.1 | +| 最后修改 | 2026-11-09 | | 对应实现 | mcpp >= 2026.10.30.1 | | 相关设计文档 | `.agents/docs/2026-10-29-ecosystem-target-sets-packaging-and-editors-design.md`(§3) | | 相关 issue | mcpp#847 | @@ -85,10 +85,10 @@ ## 4. 测试归档 - **写**:`mcpp test [--target ...] --no-run --archive ` 构建各腿的测试程序,与清单 `mcpp-tests.json`(每个测试的腿、 - 参数、工作目录)一起写为一个 tar 归档。归档是引擎的格式,与平台的容器无关。压缩方式由文件名决定 + 参数、工作目录、所实现的测试协议与其自身的文件数)一起写为一个 tar 归档。测试协议按构建时的规则确定一次,写入清单。归档是引擎的格式,与平台的容器无关。压缩方式由文件名决定 (`.tar.gz`/`.tgz`、`.tar.zst`、`.tar.xz`、`.tar`),读写同一规则;其他名称在构建前拒绝(退出码 2)。 -- **读**:`mcpp test --archive ` 在同一项目的检出中运行归档内本机能到达的测试,不构建;工作目录为项目检出;报告与 - `mcpp test` 相同。 +- **读**:`mcpp test --archive ` 在同一项目的检出中运行归档内本机能到达的测试,不构建;工作目录为项目检出;归档的测试由 `mcpp test` 自身的运行器运行: + 测试协议、按用例所在文件分进程、工作线程与 mtest 存储与构建所得的测试**必须**相同。 - 读取时:有测试失败则退出码 1;一个测试都没有运行则退出码 1 并写明;本机到达不了的腿报告为"未运行"。 - 原生交叉同样适用:在 Linux 上构建的 `x86_64-windows-gnu` 测试可以在 Windows 上运行。 @@ -121,3 +121,4 @@ | 版本 | 日期 | 变更 | |---|---|---| | 1.0 | 2026-10-30 | 首版(mcpp 2026.10.30.1,设计 2026-10-29 §3):集合的识别、补全与展开,一条命令的腿,`build`、`run`、`test`、`self env` 对多条腿的语义,串行规划与一起构建,测试归档,机器输出;判据 1–8(e2e 963、964,unit `TargetSet`、`BuildLegs`)。 | +| 1.1 | 2026-11-09 | mcpp 2026.11.9.1:归档清单记录每个测试的协议与自身文件数;读取时由 `mcpp test` 的运行器运行(协议、分进程、工作线程、存储)。 | diff --git a/docs/zh/08-testing.md b/docs/zh/08-testing.md index d6ec71db5..1da7a10a8 100644 --- a/docs/zh/08-testing.md +++ b/docs/zh/08-testing.md @@ -57,7 +57,7 @@ discover = ["checks/**/*.cpp", "!checks/fixtures/**"] 目录中的文件仍各自是一个程序。多文件的程序只链接一次,而每文件一个程序时每个文件链接 一次,后者正是由许多小文件组成的套件在构建上花费的时间。 -使用测试协议的程序**按其用例所在文件各起一个进程**运行(2026.11.9.1+):mcpp 先列出用例, +使用测试协议的程序**按其用例所在文件各起一个进程**运行(2026.11.9.1+):mcpp 先列出多文件程序的用例, 每个文件的那次运行只选取自己的用例(P4),像一个程序一样在各工作线程间调度,名为 `<程序>/<文件>`——兼得每文件一个程序的隔离与并行,而只链接一次。`[test] split = "case"` 使每个用例一个进程,`"none"` 只运行一次。 @@ -69,9 +69,10 @@ discover = ["checks/**/*.cpp", "!checks/fixtures/**"] 一次运行以 mtest 存储的形式保存(2026.11.9.1+):`target/mtest/` 中有 `runs//run.json`, `programs.ndjson` 每个进程一行(状态、用时、开始时刻、工作线程与所选用例),`cases.ndjson` -每个用例一行,`index.ndjson` 列出各次运行。`mtest report`、`analyze` 与 `graph --store target/mtest` -像读取 mtest 自身的运行一样读取它们:报告、最慢的用例与运行的调度。`[test] store = ""` -另指一个目录,相对项目;`"none"` 不保存。 +每个用例一行,`index.ndjson` 列出各次运行。`mtest store import --from target/mtest` 把它们取入 mtest 的存储, +`[project] store` 为 `target/mtest` 的 `mtest.toml` 则就地读取;`mtest report`、`analyze` 与 `graph` 随之像读取 +mtest 自身的运行一样读取它们(Markdown 报告、最慢的用例、关键路径与调度)。在 CI 中 `commit` 与 `group` 取 +`GITHUB_SHA` 与 `GITHUB_RUN_ID`。`[test] store = ""` 另指一个目录,相对项目;`"none"` 不保存。 由自身多个源文件编译而成的测试套件也可以是一个包:一个工作区成员,它的 `[build] sources` 承载整套套件,其唯一的测试程序驱动套件,用 @@ -247,8 +248,9 @@ mcpp test --target openkal --no-run --archive tests.tar.gz # 构建各腿的 mcpp test --archive tests.tar.gz # 在检出中运行本机能到达的部分 ``` -归档含各腿的测试程序与 `mcpp-tests.json`(每个测试的腿、参数与工作目录)。读取 -时不构建;测试在检出中经本机的运行器或系统运行,报告与 `mcpp test` 相同。有测试 +归档含各腿的测试程序与 `mcpp-tests.json`(每个测试的腿、参数、工作目录与所实现的测试协议)。读取 +时不构建;测试在检出中经本机的运行器或系统,由 `mcpp test` 自身的运行器运行:用例、按用例所在文件分进程、 +工作线程与存储(2026.11.9.1+)。有测试 失败时退出码为 1,一个测试都没运行的归档也是 1。本机到达不了的腿报告为未运行 ([SPEC-015 §4](../specs/target-sets.md))。归档是 tar,压缩方式由文件名决定:`.tar.gz`(或 `.tgz`)、`.tar.zst`、`.tar.xz` 或 `.tar`;其他名称在构建前即被拒绝。 diff --git a/src/build/execute.cppm b/src/build/execute.cppm index b4886a800..f74606e2b 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -94,8 +94,9 @@ namespace libc = mcpplibs::mwrap::libc::macros; // `mcpp-tests.json` (schema 1): `package`, `cwd` (the directory the tests run // in, relative to the project root: the package's own), and `legs`, each with // its `triple`, `programs` (environment name to path) and `tests` (`name`, -// `program`, the `files` beside it). Paths are relative to the archive's -// directory and use `/`. +// `program`, the `files` beside it, the test `protocol` it implements, 0 for +// none, as the build decided it, and `own_files`, the files that are its own: +// an archive is run by the same runner as a build's tests). Paths are relative to the archive's directory and use `/`. export namespace mcpp::build::test_archive { inline constexpr int kSchema = 1; @@ -105,6 +106,8 @@ struct Test { std::string name; std::string program; // relative to the archive's directory std::vector files; // beside it, relative to the archive's directory + int protocol = 0; + int ownFiles = 1; // the files that are the test's own }; struct Leg { @@ -170,7 +173,8 @@ nlohmann::json to_json(const Manifest& m) { for (auto const& [env, path] : l.programs) programs[env] = path; nlohmann::json tests = nlohmann::json::array(); for (auto const& t : l.tests) - tests.push_back({{"name", t.name}, {"program", t.program}, {"files", t.files}}); + tests.push_back({{"name", t.name}, {"program", t.program}, {"files", t.files}, + {"protocol", t.protocol}, {"own_files", t.ownFiles}}); legs.push_back({{"triple", l.triple}, {"programs", std::move(programs)}, {"tests", std::move(tests)}}); } @@ -195,7 +199,8 @@ std::expected from_json(const nlohmann::json& d) { l.programs.emplace_back(it.key(), it.value().get()); if (jl.contains("tests") && jl["tests"].is_array()) for (auto const& jt : jl["tests"]) { - Test t{jt.value("name", ""), jt.value("program", ""), {}}; + Test t{jt.value("name", ""), jt.value("program", ""), {}, jt.value("protocol", 0), + jt.value("own_files", 1)}; if (jt.contains("files") && jt["files"].is_array()) for (auto const& f : jt["files"]) t.files.push_back(f.get()); l.tests.push_back(std::move(t)); @@ -4130,7 +4135,8 @@ void write_mtest_store(const std::filesystem::path& dir, const std::vector argv; + std::vector> env; + long long buildMs = 0; // this test's own edges in .ninja_log + std::filesystem::path program; // the test's own artifact + // The test protocol the program implements (0: none, P1.1), and the + // files of its run under it: the report, the selection, the cases' + // directory (docs/08 "Cases"). + int protocol = 0; + std::filesystem::path protocolDir; + std::optional select; + std::vector sources; + std::string guide; // its framework's guide (docs/08 "Writing a test") + std::filesystem::path cwd; // empty: mcpp's own + // The files that are the test's own: a program of one file has no other + // file to run its cases in, and is not listed to be split by file. + std::size_t ownFiles = 1; +}; + +// THE PROTOCOL A TEST PROGRAM IMPLEMENTS (P2.3): what its entry states, else +// the marker its file carries. A test that keeps what it reaches may have lost +// a marker nothing references (a framework before mtest 0.5.1) at the link; +// the objects it was linked from still carry it, and the program is read as it +// was before the link dropped anything. `objectMarkers` holds each object's +// answer, so that one is read once per run. +static int test_protocol_of(const BuildContext& ctx, const LinkUnit& lu, + std::map& objectMarkers) { + namespace tp = mcpp::build::test_protocol; + if (lu.testProtocol) return lu.testProtocol; + if (const int v = tp::marker_version(ctx.outputDir / lu.output)) return v; + const bool collected = std::ranges::any_of(lu.linkFlags, [](std::string_view f) { + return f == "-Wl,--gc-sections" || f == "-Wl,-dead_strip"; + }); + if (!collected) return 0; + for (auto const& o : lu.objects) { + const auto path = o.is_absolute() ? o : ctx.outputDir / o; + auto [it, fresh] = objectMarkers.try_emplace(path, 0); + if (fresh) it->second = tp::marker_version(path); + if (it->second == tp::kVersion) return it->second; + } + return 0; +} + struct TestBuild { + // The tests of an archive (`mcpp test --archive`), composed by its reader: + // nothing is built, and these run in place of the plan's. + std::optional> archived; std::optional ctx; // The tests a change affects (`--since`, `--changed`); none: every test. std::optional> affected; @@ -4721,21 +4775,7 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, return j > 0 ? j : 1; }(); - struct Runnable { - std::string name; - std::vector argv; - std::vector> env; - long long buildMs = 0; // this test's own edges in .ninja_log - std::filesystem::path program; // the test's own artifact - // The test protocol the program implements (0: none, P1.1), and the - // files of its run under it: the report, the selection, the cases' - // directory (docs/08 "Cases"). - int protocol = 0; - std::filesystem::path protocolDir; - std::optional select; - std::vector sources; - std::string guide; // its framework's guide (docs/08 "Writing a test") - }; + using Runnable = TestRunnable; std::vector runnable; // Executes `list`, appending to `results` and emitting the per-test line. @@ -4870,6 +4910,7 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // it, stated for this run, and no other program (P1.2, P2.2). mcpp::run::Options runOpts; runOpts.env = r.env; + runOpts.cwd = r.cwd.string(); runOpts.input = mcpp::run::Input::inherit; runOpts.streams = capture ? mcpp::run::Streams::merged : mcpp::run::Streams::inherit; runOpts.total = deadline; @@ -5047,28 +5088,16 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, if (!std::isalnum(static_cast(ch)) && ch != '-' && ch != '.') ch = '_'; return ctx->outputDir / "test-protocol" / leaf; }; - // THE MARKER OF THE OBJECTS A TEST WAS LINKED FROM (P2.3). A test keeps what - // it reaches, and a framework whose marker nothing references (mtest before - // 0.5.1) loses it at the link; its object still carries it, and the program - // is then read as it was before the link dropped anything. Each object is - // read once per run. std::map objectMarkers; - auto marker_of_objects = [&](const mcpp::build::LinkUnit& lu) { - const bool collected = std::ranges::any_of(lu.linkFlags, [](std::string_view f) { - return f == "-Wl,--gc-sections" || f == "-Wl,-dead_strip"; - }); - if (!collected) return 0; - for (auto const& o : lu.objects) { - const auto path = o.is_absolute() ? o : ctx->outputDir / o; - auto [it, fresh] = objectMarkers.try_emplace(path, 0); - if (fresh) it->second = mcpp::build::test_protocol::marker_version(path); - if (it->second == mcpp::build::test_protocol::kVersion) return it->second; - } - return 0; - }; + // An archive's tests are composed by its reader; they run as the plan's do. + if (tb.archived) { + runnable = *tb.archived; + for (auto& r : runnable) + if (r.protocol) r.protocolDir = protocol_dir(r.name); + } for (auto& lu : ctx->plan.linkUnits) { - if (!filter_match(lu)) continue; + if (tb.archived || !filter_match(lu)) continue; auto tTest = std::chrono::steady_clock::now(); auto test_ms = [&tTest] { @@ -5183,15 +5212,14 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // its file carries (P2.3). A version this mcpp does not speak runs as // a program that implements none. auto& added = runnable.back(); - int version = lu.testProtocol ? lu.testProtocol - : mcpp::build::test_protocol::marker_version(exe); - if (version == 0) version = marker_of_objects(lu); + const int version = test_protocol_of(*ctx, lu, objectMarkers); if (version == mcpp::build::test_protocol::kVersion) { added.protocol = version; added.protocolDir = protocol_dir(lu.targetName); } added.sources = sources_of(lu); added.guide = guide_of(lu, version); + added.ownFiles = std::max(lu.ownSources.size(), 1); } // `mcpp test --print-launch` (design 2026-10-30 part 2 §7.4): the one test the pattern // names, built, described as `mcpp run --print-launch` describes a program -- the `mcpp.launch` @@ -5269,6 +5297,7 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, } mcpp::run::Options listOpts; listOpts.env = r.env; + listOpts.cwd = r.cwd.string(); state_protocol(r, "list", listOpts); listOpts.streams = mcpp::run::Streams::merged; listOpts.total = std::chrono::milliseconds(static_cast(testOpts.timeoutSecs) * 1000); @@ -5340,12 +5369,13 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, if (invocationNotRunReason.empty() && split != "none") { std::vector expanded; for (auto& r : runnable) { - if (!r.protocol) { + if (!r.protocol || (split == "file" && r.ownFiles < 2)) { expanded.push_back(std::move(r)); continue; } mcpp::run::Options listOpts; listOpts.env = r.env; + listOpts.cwd = r.cwd.string(); state_protocol(r, "list", listOpts); listOpts.streams = mcpp::run::Streams::merged; listOpts.total = std::chrono::milliseconds(static_cast(testOpts.timeoutSecs) * 1000); @@ -5385,14 +5415,16 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, runnable = std::move(expanded); } run_tests_now(runnable); - // The run, kept in the mtest store's form (`[test] store`). - if (const auto& st = ctx->manifest.testStore; st != "none" && !results.empty()) - write_mtest_store(st.empty() ? ctx->projectRoot / "target" / "mtest" : ctx->projectRoot / st, results, - testStartedWall, - std::chrono::duration_cast( - std::chrono::steady_clock::now() - tLoop).count(), - runJobs, testOpts.filter); } + // The run, kept in the mtest store's form (`[test] store`): one that ran a + // program, and not a listing. + if (const auto& st = ctx->manifest.testStore; st != "none" && !testOpts.listCases + && std::ranges::any_of(results, [](const TestResult& r) { return r.status != TestResult::St::Built; })) + write_mtest_store(st.empty() ? ctx->projectRoot / "target" / "mtest" : ctx->projectRoot / st, results, + testStartedWall, + std::chrono::duration_cast( + std::chrono::steady_clock::now() - tLoop).count(), + runJobs, testOpts.filter); summary.elapsedMs = carriedMs + std::chrono::duration_cast( std::chrono::steady_clock::now() - tLoop).count(); @@ -5515,6 +5547,7 @@ int write_test_archive(const TestOptions& o, std::span legs) { auto rel = cwd.lexically_relative(first.projectRoot.lexically_normal()); if (!rel.empty() && *rel.begin() != ".." && rel != ".") m.cwd = rel.generic_string(); std::vector copies; + std::map objectMarkers; std::size_t count = 0; for (auto const& l : legs) { ta::Leg leg; @@ -5524,7 +5557,9 @@ int write_test_archive(const TestOptions& o, std::span legs) { if (!o.filter.empty() && lu.targetName.find(o.filter) == std::string::npos) continue; const auto exe = l.ctx->outputDir / lu.output; if (!fs::is_regular_file(exe, ec)) continue; - ta::Test t{lu.targetName, ta::test_dest(l.triple, lu.targetName, exe.filename().string()), {}}; + ta::Test t{lu.targetName, ta::test_dest(l.triple, lu.targetName, exe.filename().string()), {}, + test_protocol_of(*l.ctx, lu, objectMarkers), + static_cast(std::max(lu.ownSources.size(), 1))}; copies.push_back({exe, t.program}); // The files of the test's member, placed in its product directory. std::vector> carried; @@ -5585,7 +5620,6 @@ export int run_test_archive(std::span passthrough, BuildOverr const auto cwd = opened->manifest.cwd.empty() ? *root : *root / opened->manifest.cwd; int passed = 0, failed = 0, notRun = 0, notReached = 0; std::vector reasons; - const auto deadline = std::chrono::milliseconds(static_cast(testOpts.timeoutSecs) * 1000); for (auto const& leg : opened->manifest.legs) { if (json) std::println("{{\"leg\":{{\"triple\":\"{}\"}}}}", test_json_escape(leg.triple)); else mcpp::ui::info("Testing", std::format("on {} (from {})", leg.triple, testOpts.archive.string())); @@ -5618,62 +5652,50 @@ export int run_test_archive(std::span passthrough, BuildOverr legEnv.emplace_back(env, (opened->root / path).lexically_normal().string()); legEnv.emplace_back("MCPP_RUN_TRIPLE", tb.runnerChoice.tripleKey); legEnv.emplace_back("MCPP_RUN_MODE", "test"); + // THE LEG'S TESTS, RUN AS A BUILD'S ARE: through the runner, the + // protocol each implements, a process per file of its cases, the + // workers, the store (SPEC-015 §4; docs/08). + std::vector runs; for (auto const& t : leg.tests) { if (!testOpts.filter.empty() && t.name.find(testOpts.filter) == std::string::npos) continue; - const auto exe = (opened->root / t.program).lexically_normal(); - std::vector argv; - if (tb.runnerTmpl.empty()) argv.push_back(exe.string()); - else argv = mcpp::freestanding::expand(tb.runnerTmpl, exe); - for (auto const& a : passthrough) argv.push_back(a); - argv.insert(argv.begin(), tb.runUnderPrefix.begin(), tb.runUnderPrefix.end()); - auto env = legEnv; + TestRunnable r; + r.name = t.name; + r.program = (opened->root / t.program).lexically_normal(); + if (tb.runnerTmpl.empty()) r.argv.push_back(r.program.string()); + else r.argv = mcpp::freestanding::expand(tb.runnerTmpl, r.program); + for (auto const& a : passthrough) r.argv.push_back(a); + r.argv.insert(r.argv.begin(), tb.runUnderPrefix.begin(), tb.runUnderPrefix.end()); + r.env = legEnv; // The files beside the program are its own: the loader searches its // directory, and a runner reads the list it is handed. macOS names // no such variable (its programs keep their rpaths); an entry of an // empty name is refused by the system, and every program of a macOS // leg was "invalid argument" (e2e 989). - std::array dir{exe.parent_path()}; + std::array dir{r.program.parent_path()}; if (const auto key = mcpp::runtime::launch_env::runtime_library_path_key(); !key.empty()) - env.emplace_back(key, mcpp::runtime::launch_env::prepend_path_list(key, dir)); + r.env.emplace_back(key, mcpp::runtime::launch_env::prepend_path_list(key, dir)); { - const auto list = exe.parent_path() / ".mcpp-runtime-files.tsv"; + const auto list = r.program.parent_path() / ".mcpp-runtime-files.tsv"; std::ofstream os(list, std::ios::binary | std::ios::trunc); for (auto const& f : t.files) os << fs::path(f).filename().generic_string() << '\t' << (opened->root / f).lexically_normal().string() << '\n'; - env.emplace_back(std::string(kRuntimeFilesEnv), list.string()); - } - const auto t0 = std::chrono::steady_clock::now(); - bool timedOut = false; - int spawnErr = 0; - const auto cwdText = cwd.string(); - auto rr = mcpp::run::capture_exec_deadline(argv, env, deadline, &timedOut, cwdText, &spawnErr); - const auto ms = std::chrono::duration_cast( - std::chrono::steady_clock::now() - t0).count(); - const bool ok = spawnErr == 0 && !timedOut && rr.exit_code == 0; - if (spawnErr != 0) { - ++notRun; - reasons.push_back(std::format("{}: {}", leg.triple, - mcpp::build::runner_lookup::spawn_failed_message(argv.front(), spawnErr, - rr.spawn_detail))); - } else if (ok) { - ++passed; - } else { - ++failed; - } - if (json) { - std::println("{{\"test\":\"{}\",\"triple\":\"{}\",\"status\":\"{}\",\"ms\":{}}}", - test_json_escape(t.name), test_json_escape(leg.triple), - spawnErr ? "not_run" : ok ? "pass" : timedOut ? "timeout" : "fail", ms); - } else { - mcpp::ui::plain(std::format("{} ... {} ({:.2f}s)", t.name, - spawnErr ? "NOT RUN" : ok ? "ok" : timedOut - ? std::format("FAIL (timeout after {}s)", testOpts.timeoutSecs) - : std::format("FAIL (exit {})", rr.exit_code), - static_cast(ms) / 1000.0)); - if (!ok && spawnErr == 0 && !rr.output.empty()) mcpp::ui::block(rr.output); + r.env.emplace_back(std::string(kRuntimeFilesEnv), list.string()); } + r.protocol = t.protocol == mcpp::build::test_protocol::kVersion ? t.protocol : 0; + r.ownFiles = static_cast(std::max(t.ownFiles, 1)); + r.cwd = cwd; + runs.push_back(std::move(r)); } + if (runs.empty()) continue; + tb.archived = std::move(runs); + TestRunSummary s; + test_run_member(tb, o, passthrough, json, opened->manifest.package, {}, 0, s); + passed += s.passed; + failed += s.failed; + notRun += s.notRun; + if (s.notRun && !s.notRunReason.empty()) + reasons.push_back(std::format("{}: {}", leg.triple, s.notRunReason.substr(0, s.notRunReason.find('\n')))); } if (json) { std::println("{{\"archive_summary\":{{\"passed\":{},\"failed\":{},\"not_run\":{}," @@ -5690,8 +5712,9 @@ export int run_test_archive(std::span passthrough, BuildOverr const auto counts = std::format("{} passed; {} failed{}{}", passed, failed, notReached ? std::format("; {} not run here", notReached) : std::string{}, notRun ? std::format("; {} not run", notRun) : std::string{}); - if (failed) { mcpp::ui::error(std::format("test result: FAILED. {}", counts)); return 1; } - if (!json) mcpp::ui::result("test result", std::format("ok. {}", counts)); + // The archive's result over its legs, each of which stated its own. + if (failed) { mcpp::ui::error(std::format("archive result: FAILED. {}", counts)); return 1; } + if (!json) mcpp::ui::result("archive result", std::format("ok. {}", counts)); return notRun ? 2 : 0; } diff --git a/tests/e2e/1050_a_test_run_is_kept_in_the_mtest_store.sh b/tests/e2e/1050_a_test_run_is_kept_in_the_mtest_store.sh index a9a1f9d4f..4cfa6c801 100755 --- a/tests/e2e/1050_a_test_run_is_kept_in_the_mtest_store.sh +++ b/tests/e2e/1050_a_test_run_is_kept_in_the_mtest_store.sh @@ -8,6 +8,7 @@ # # Criteria: a run is one record of the index with its counts; each process states its status, # duration, start and worker; each case its program and status; a second run is a second record; +# the tests of an archive run as the build's do -- their cases reported, the run stored; # `store = "none"` adds none; `store = ""` writes there. set -e TMP=$(mktemp -d) @@ -40,9 +41,14 @@ echo " ok a run is kept with its processes and cases" "$MCPP" test > ../t2.log 2>&1 || fail "the second run failed" ../t2.log [ "$(wc -l < target/mtest/index.ndjson | tr -d ' ')" = 2 ] || fail "a second run is not a second record" target/mtest/index.ndjson +"$MCPP" test --no-run --archive ../tests.tar.gz > ../a1.log 2>&1 || fail "the archive was not written" ../a1.log +"$MCPP" test --archive ../tests.tar.gz > ../a2.log 2>&1 || fail "the archive's tests failed" ../a2.log +grep -q 'test_smoke \.\.\. ok (.*1 case)' ../a2.log || fail "an archived protocol program does not report its case" ../a2.log +[ "$(wc -l < target/mtest/index.ndjson | tr -d ' ')" = 3 ] || fail "the archive's run is not stored" target/mtest/index.ndjson +echo " ok an archive's tests run as the build's: their case reported, the run stored" printf '\n[test]\nstore = "none"\n' >> mcpp.toml "$MCPP" test > ../t3.log 2>&1 || fail "the third run failed" ../t3.log -[ "$(wc -l < target/mtest/index.ndjson | tr -d ' ')" = 2 ] || fail "store = none kept a run" target/mtest/index.ndjson +[ "$(wc -l < target/mtest/index.ndjson | tr -d ' ')" = 3 ] || fail "store = none kept a run" target/mtest/index.ndjson sed -i.bak 's|^store = "none"$|store = "out/test-runs"|' mcpp.toml "$MCPP" test > ../t4.log 2>&1 || fail "the fourth run failed" ../t4.log [ -f out/test-runs/index.ndjson ] || fail "store = did not write there" ../t4.log From a31c7620f0e45eb61692522b3c3863b6068e3786 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 13:00:13 +0900 Subject: [PATCH 28/45] fix: a cross target's toolchain section is not the build program's; a test name is not another's directory; CI findings - Under a cross `--target`, a `[target.] toolchain` names the target's compiler; the build program takes the spec it replaced, as under a row's pin (mcpp's own tree built on Windows for x86_64-linux-musl compiled its build program with the musl cross gcc). e2e 1051. - A test name that is another's directory (`unit`, `unit/own`) is refused, naming both. - The rows' snapshot keeps LF on every checkout (.gitattributes). - e2e 298 leaves out x86_64-windows-gnu, whose MinGW-w64 payload llvm pairs with; openkal-cross names the openkal macOS identity, aarch64-macos-musl. - The stray empty `.edited` is removed; e2e 859 names a missing record. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .edited | 0 .gitattributes | 2 + .github/workflows/openkal-cross.yml | 16 ++++--- CHANGELOG.md | 8 ++++ docs/08-testing.md | 4 +- docs/zh/08-testing.md | 3 +- src/build/prepare/toolchain.cpp | 6 +++ src/build/prepare/xlings.cpp | 14 ++++-- src/build/test_targets.cppm | 11 +++++ ...chain_section_is_not_the_build_programs.sh | 46 +++++++++++++++++++ ...ding_a_convention_requires_replacing_it.sh | 4 ++ ..._entry_is_reused_while_its_inputs_agree.sh | 3 +- tests/unit/test_test_targets.cpp | 29 ++++++++++++ 13 files changed, 132 insertions(+), 14 deletions(-) delete mode 100644 .edited create mode 100755 tests/e2e/1051_a_cross_targets_toolchain_section_is_not_the_build_programs.sh diff --git a/.edited b/.edited deleted file mode 100644 index e69de29bb..000000000 diff --git a/.gitattributes b/.gitattributes index 24725dce8..647206538 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,4 @@ # The cross-host admission compares the exact source bytes compiled by each host. tests/fixtures/openkal-hosted-threads/src/main.cpp.in text eol=lf +# The rows' snapshot is compared line by line with what the engine answers, in its own bytes. +tests/fixtures/target-rows.snapshot text eol=lf diff --git a/.github/workflows/openkal-cross.yml b/.github/workflows/openkal-cross.yml index 06c015660..689aeae08 100644 --- a/.github/workflows/openkal-cross.yml +++ b/.github/workflows/openkal-cross.yml @@ -292,7 +292,9 @@ jobs: # `openkal-llvm-runtime`'s own CI under qemu; it has no runner here to # execute on, and a build-only cell in a workflow whose point is # running would be the weaker claim. - for t in x86_64-linux-gnu aarch64-macos x86_64-windows-gnu; do + # The macOS identity over openkal is `aarch64-macos-musl` on every host (SPEC-016 §4): on a + # macOS host `aarch64-macos` is the Apple SDK's. + for t in x86_64-linux-gnu aarch64-macos-musl x86_64-windows-gnu; do rm -rf target # The upstream manifest may pin an older LLVM line. The invocation # selects this candidate explicitly, including on the ARM64 host. @@ -301,12 +303,12 @@ jobs: [ -n "$a" ] || { echo "::error::$t produced no artefact on ${{ matrix.host }}"; exit 1; } case "$t" in x86_64-windows-gnu) cp "$a" "$RUNNER_TEMP/out/windows.exe" ;; - aarch64-macos) cp "$a" "$RUNNER_TEMP/out/macos" ;; + aarch64-macos-musl) cp "$a" "$RUNNER_TEMP/out/macos" ;; *) cp "$a" "$RUNNER_TEMP/out/linux" ;; esac "$READOBJ" --file-headers "$a" > "$RUNNER_TEMP/out/$t.headers.txt" case "$t" in - aarch64-macos) arch=aarch64 ;; + aarch64-macos-musl) arch=aarch64 ;; *) arch=x86_64 ;; esac grep -q "Arch: $arch" "$RUNNER_TEMP/out/$t.headers.txt" \ @@ -319,7 +321,7 @@ jobs: thread_bin=$(cat "$RUNNER_TEMP/threads-binary.txt") case "$t" in x86_64-windows-gnu) thread_output=windows-threads.exe ;; - aarch64-macos) thread_output=macos-threads ;; + aarch64-macos-musl) thread_output=macos-threads ;; *) thread_output=linux-threads ;; esac cp "$thread_bin" "$RUNNER_TEMP/out/$thread_output" @@ -355,7 +357,7 @@ jobs: # costs. # # LEG 3 IS THE ENGINE: the same idiom, over the openkal stack, for - # `aarch64-macos`, built by the mcpp under test. Measured on the token + # `aarch64-macos-musl`, built by the mcpp under test. Measured on the token # this step was written for, the two readings are # # -fno-builtin-memset_pattern16 1 reference, and the link fails @@ -444,10 +446,10 @@ jobs: [ "$(refs "$W/narrow.o")" = 1 ] || { echo "::error::leg 2: clang now honours -fno-builtin-memset_pattern16, and mcpp.toolchain.cenv can emit the narrower token"; exit 1; } [ "$(refs "$W/blunt.o")" = 0 ] || { echo "::error::leg 2: -fno-builtin no longer withdraws the pattern fill"; exit 1; } - (cd "$W" && "$MCPP_UNDER_TEST" build --target aarch64-macos) + (cd "$W" && "$MCPP_UNDER_TEST" build --target aarch64-macos-musl) obj=$(find "$W/target" -name 'main.o' | head -1) [ -n "$obj" ] || { echo "::error::leg 3 produced no object to read"; exit 1; } - echo "engine, aarch64-macos over openkal: $(refs "$obj")" + echo "engine, aarch64-macos-musl over openkal: $(refs "$obj")" [ "$(refs "$obj")" = 0 ] || { echo "::error::leg 3: builtins = \"iso\" did not withdraw memset_pattern16"; exit 1; } - uses: actions/upload-artifact@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index b7dfded50..57a791d20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,14 @@ the conflict is reported (B2). - `mcpp pack` no longer packs one program of several without a word (B3). - A static ELF is not run to learn its closure. +- A build program under a cross `--target` whose `[target.] toolchain` + names the target's compiler is compiled by the spec that section replaced, + as under a row's pin: mcpp's own tree, built on Windows for + `x86_64-linux-musl`, compiled its build program with the musl cross gcc. +- A test name that is another's directory (`unit` and `unit/own`) is refused, + naming both; the two programs were a file and a directory of one path. +- The repository's stray empty `.edited` is removed; e2e 859 fails naming the + missing record rather than writing one in the working directory. ## [2026.11.8.1] - 2026-11-08 diff --git a/docs/08-testing.md b/docs/08-testing.md index 0a4c486e4..d44a2548a 100644 --- a/docs/08-testing.md +++ b/docs/08-testing.md @@ -61,7 +61,9 @@ below a glob's fixed directory one program, named by the directory it, its cases are all of theirs, and it begins at the file that defines `main` when one does, else as a file that defines none does. Two files of one directory that define `main` are refused, naming both; a file directly in the fixed -directory stays a program of its own. A program of many files is linked once +directory stays a program of its own. A test's name is the path of its program, +so a name that is another's directory (`unit` and `unit/own`) is refused, +naming both (2026.11.9.1+). A program of many files is linked once where a program per file is linked once per file, which is what a suite of many small files spends its build time on. diff --git a/docs/zh/08-testing.md b/docs/zh/08-testing.md index 1da7a10a8..61133c305 100644 --- a/docs/zh/08-testing.md +++ b/docs/zh/08-testing.md @@ -54,7 +54,8 @@ discover = ["checks/**/*.cpp", "!checks/fixtures/**"] 一个程序,以目录命名(`tests/unit/*.cpp` 为程序 `unit`):每个文件是它的一个翻译单元, 它的用例是这些文件的全部用例;有文件定义 `main` 时以该文件为起点,否则按不定义 `main` 的文件的规则开始。同一目录中两个定义 `main` 的文件会被拒绝并点出两者;直接位于固定 -目录中的文件仍各自是一个程序。多文件的程序只链接一次,而每文件一个程序时每个文件链接 +目录中的文件仍各自是一个程序。测试的名字是其程序的路径,因此一个名字是另一个名字的目录 +(`unit` 与 `unit/own`)时被拒绝并点出两者(2026.11.9.1+)。多文件的程序只链接一次,而每文件一个程序时每个文件链接 一次,后者正是由许多小文件组成的套件在构建上花费的时间。 使用测试协议的程序**按其用例所在文件各起一个进程**运行(2026.11.9.1+):mcpp 先列出多文件程序的用例, diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index 9efad92f8..e90995521 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -550,6 +550,12 @@ static void step1_apply_target_section(PrepareState& state, : state.overrides.target_triple, state.m->sourcePath.filename().string())); if (!e.toolchain.empty() && !state.tcFromCommandLine && !state.tcFromConsumer) { + // A CROSS TARGET'S SECTION NAMES THE TARGET'S COMPILER, NOT THE HOST'S: + // the spec it replaced is what the build program resolves, as a row's + // pin's is (`[target.x86_64-linux-musl] toolchain = "gcc@16.1.0"` on a + // Windows host whose `[toolchain] windows` is llvm). + if (!state.overrides.target_triple.empty() && state.tcSpec && *state.tcSpec != e.toolchain) + state.hostSpecBeforeRowPin = *state.tcSpec; state.tcSpec = e.toolchain; state.tcOrigin = TcOrigin::TargetSection; } diff --git a/src/build/prepare/xlings.cpp b/src/build/prepare/xlings.cpp index 27e924c2a..5a58aeef3 100644 --- a/src/build/prepare/xlings.cpp +++ b/src/build/prepare/xlings.cpp @@ -121,10 +121,16 @@ step3_define_host_tc_closures_and_refresh_index(PrepareState& state) { if (!state.bootstrapSpec.empty()) return state.bootstrapSpec; if (!state.tcSpec) return {}; if (state.overrides.target_triple.empty()) return *state.tcSpec; - return (state.tcOrigin == TcOrigin::TargetPin && state.hostSpecBeforeRowPin.has_value() - && !state.hostSpecBeforeRowPin->empty() && *state.hostSpecBeforeRowPin != "system") - ? *state.hostSpecBeforeRowPin - : (state.tcOrigin == TcOrigin::TargetPin ? state.native_first_run_spec() : *state.tcSpec); + // The target's own choice -- a row's pin or a `[target.] toolchain` + // -- is not the host's: the spec it replaced is. A pin that replaced + // nothing gives the native default (#622); a section that replaced + // nothing names the only compiler the project states. + const bool targetChose = state.tcOrigin == TcOrigin::TargetPin + || state.tcOrigin == TcOrigin::TargetSection; + if (targetChose && state.hostSpecBeforeRowPin.has_value() && !state.hostSpecBeforeRowPin->empty() + && *state.hostSpecBeforeRowPin != "system") + return *state.hostSpecBeforeRowPin; + return state.tcOrigin == TcOrigin::TargetPin ? state.native_first_run_spec() : *state.tcSpec; }; state.host_tc_for_build_program = [&]() -> std::expected< std::pair, std::string> { diff --git a/src/build/test_targets.cppm b/src/build/test_targets.cppm index 732959ed1..d9c428f02 100644 --- a/src/build/test_targets.cppm +++ b/src/build/test_targets.cppm @@ -192,6 +192,17 @@ discover_test_targets(const std::filesystem::path& manifestRoot, } result.targets.push_back(std::move(target)); } + // A TEST'S NAME IS A PATH OF ITS PROGRAM (`bin/`): one name that is + // another's directory would make one program a directory of the other's. + // The names are sorted: the first name below `/` is the one to ask. + for (auto it = seenNames.begin(); it != seenNames.end(); ++it) { + auto next = seenNames.lower_bound(it->first + "/"); + if (next == seenNames.end() || !next->first.starts_with(it->first + "/")) continue; + return std::unexpected(std::format( + "test '{}' ({}) is a directory of test '{}' ({}): a test's name is the path of its " + "program, and one cannot be both; move one of them", + it->first, it->second.generic_string(), next->first, next->second.generic_string())); + } return result; } diff --git a/tests/e2e/1051_a_cross_targets_toolchain_section_is_not_the_build_programs.sh b/tests/e2e/1051_a_cross_targets_toolchain_section_is_not_the_build_programs.sh new file mode 100755 index 000000000..c36a6408c --- /dev/null +++ b/tests/e2e/1051_a_cross_targets_toolchain_section_is_not_the_build_programs.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# requires: elf llvm gcc musl +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1051 -- a cross target's `[target.] toolchain` names the target's compiler, not the one the +# build program is compiled with. +# +# A build program is compiled and run on this machine. `[target.x86_64-linux-musl] toolchain = +# "gcc@16.1.0"` replaces the project's `[toolchain]` for the target; the build program takes the +# spec it replaced, as it takes the one a row's pin replaced (657). Measured on mcpp's own tree, +# built on Windows for x86_64-linux-musl: the build program was compiled by the musl cross gcc and +# the host could not start it (error 8, executable format error). +# +# Criteria: under `--target x86_64-linux-musl` the build program's compiler is the `[toolchain]` +# one (llvm), the target's is the section's (gcc), and the build program runs. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +[ "$(uname -m)" = x86_64 ] || { echo "SKIP: an x86_64 host"; exit 0; } +mkdir -p "$t/src" && cd "$t" +cat > mcpp.toml < build.mcpp <<'EOF' +import std; +import mcpp; +int main() { + std::ofstream(std::string(mcpp::out_dir()) + "/ran.txt") << "ran\n"; +} +EOF +echo 'int main() { return 0; }' > src/main.cpp +out=$("$MCPP" build --target x86_64-linux-musl 2>&1) || fail "the cross build failed" "$out" +grep -q 'host toolchain for build.mcpp: llvm' <<<"$out" \ + || grep -Eq 'Resolved host toolchain for build.mcpp: clang' <<<"$out" \ + || fail "the build program is not compiled by the [toolchain] compiler" "$out" +grep -q 'x86_64-linux-musl-g++' <<<"$out" || fail "the target is not compiled by the section's gcc" "$out" +find target -name ran.txt | grep -q . || fail "the build program did not run" "$(find target -maxdepth 4 | head)" +echo " ok the build program takes the [toolchain] compiler; the target takes its section's" +echo "PASS: 1051 a cross target's toolchain section is not the build program's" diff --git a/tests/e2e/298_overriding_a_convention_requires_replacing_it.sh b/tests/e2e/298_overriding_a_convention_requires_replacing_it.sh index f8a20b9e9..63ae7c6aa 100644 --- a/tests/e2e/298_overriding_a_convention_requires_replacing_it.sh +++ b/tests/e2e/298_overriding_a_convention_requires_replacing_it.sh @@ -82,10 +82,14 @@ fail=0 # change. hostArch="$("$MCPP" toolchain list --format json 2>/dev/null \ | jq -r '.data.host // "" | split("-")[0]' | tr -d '\r')" +# A ROW WHOSE PAYLOAD llvm PAIRS WITH IS NOT ONE. The MinGW-w64 payload is +# clang's sysroot as well as gcc's (SPEC-016 §3, e2e 1044): `x86_64-windows-gnu` +# with llvm alone is served, not overridden, and is left out here. pick() { "$MCPP" toolchain list --format json 2>/dev/null \ | jq -r --arg a "$1" '[.data.targets[] | select(.pin | startswith("gcc")) + | select(.target != "x86_64-windows-gnu") | select(.status != "planned") | select($a == "" or (.target | startswith($a + "-"))) | .target][0] // empty' | tr -d '\r' diff --git a/tests/e2e/859_a_host_module_entry_is_reused_while_its_inputs_agree.sh b/tests/e2e/859_a_host_module_entry_is_reused_while_its_inputs_agree.sh index dfcf95b9e..f6338889a 100755 --- a/tests/e2e/859_a_host_module_entry_is_reused_while_its_inputs_agree.sh +++ b/tests/e2e/859_a_host_module_entry_is_reused_while_its_inputs_agree.sh @@ -117,7 +117,8 @@ rerun 3 c.log grep -q "T859 value two" c.log || fail "C: the program took the compile made before the header changed" c.log # D -rec=$(ls $HOST/*/programs.json | head -1) +rec=$(ls $HOST/*/programs.json 2>/dev/null | head -1) +[ -n "$rec" ] || fail "D: no build programs' record under $HOST" c.log sed 's/"identity": "[0-9a-f]*"/"identity": "0000000000000000"/' "$rec" > "$rec.edited" && mv "$rec.edited" "$rec" rerun 4 d.log [ "$(compiled d.log)" = 1 ] || fail "D: a record that disagrees was used without compiling" d.log diff --git a/tests/unit/test_test_targets.cpp b/tests/unit/test_test_targets.cpp index 54c4f8a03..28d0a2b27 100644 --- a/tests/unit/test_test_targets.cpp +++ b/tests/unit/test_test_targets.cpp @@ -223,3 +223,32 @@ UTest broken_manifest_still_returns_inventory{"TestTargets/BrokenManifestStillRe }}; } // namespace + +// A test's name is the path of its program: a directory program `unit` and a +// program `unit/own` below it would be a file and a directory of one path. +UTest a_name_that_is_another_s_directory_is_refused{"TestTargets/ANameThatIsAnothersDirectoryIsRefused", [] { + Tmp tmp; + write_manifest(tmp.path, R"([package] +name = "demo" +version = "0.1.0" + +[test] +programs = "directory" +)"); + write_file(tmp.path / "tests/unit/a.cpp", "int main() {}\n"); + write_file(tmp.path / "tests/unit/b.cpp", "int b() { return 0; }\n"); + write_file(tmp.path / "tests/unit/own/c.cpp", "int main() {}\n"); + write_file(tmp.path / "tests/unit-x/d.cpp", "int main() {}\n"); + + auto result = mcpp::build::discover_test_targets(tmp.path, {}); + expect(fatal(!result.has_value())); + if (auto&& failed = expect(result.error().find("test 'unit'") != std::string::npos + && result.error().find("test 'unit/own'") != std::string::npos); + !failed) + failed << result.error(); + + std::filesystem::rename(tmp.path / "tests/unit/own", tmp.path / "tests/own"); + auto moved = mcpp::build::discover_test_targets(tmp.path, {}); + if (auto&& failed = expect(fatal(moved.has_value())); !failed) failed << moved.error(); + expect(eq(moved->targets.size(), 3u)); +}}; From 472829aba340f8a782bc97e87d30755011f61fb9 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 13:08:02 +0900 Subject: [PATCH 29/45] graph: the package being built stands for itself in its tests' graph A dependency reached through the root's [dev-dependencies] that names the root is the root: a framework that uses the package under test (mtest and mcpplibs.os) is built against it rather than against a second copy, which would provide every module twice in one program. The root's dev edges serve its tests only, so they close no cycle of packages and fold into no key. e2e 1052; docs/05. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 3 + docs/05-dependencies.md | 10 +++ docs/zh/05-dependencies.md | 5 ++ src/build/prepare/graph.cpp | 29 ++++++++- src/build/prepare/plan.cpp | 10 ++- ..._package_under_test_is_built_against_it.sh | 63 +++++++++++++++++++ 6 files changed, 117 insertions(+), 3 deletions(-) create mode 100755 tests/e2e/1052_a_framework_that_uses_the_package_under_test_is_built_against_it.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a791d20..92e7589d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,9 @@ `mcpp why platform` lists the rows a platform defines and the identities it serves, with their providers and priorities. - `mcpp pack --all-programs` ships every program of a package. +- The package being built stands for itself in its own graph: a test framework + that uses the package under test (mtest and mcpplibs.os) is built against it, + and the root's `[dev-dependencies]` close no cycle of packages. - A git reference that names no tag, rev or branch follows the remote's default branch; `--platform git+[#tag=|rev=|branch=]`. diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index aa65613e0..a7184cbfd 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -454,6 +454,16 @@ gtest = "1.15.2" `mcpp build` ignores these; `mcpp test` resolves and uses them, for the test units only: the package's own sources compile under `mcpp test` as under `mcpp build` (2026.11.8.1+, [08](08-testing.md#tests-that-reach-packages-the-artifact-does-not)). `mcpp test` automatically discovers `tests/**/*.cpp` and compiles them into test binaries. The runner is framework-agnostic: each file is an independent binary judged by exit code — a bare `main`, gtest (via `[dev-dependencies]` + `gtest_main`), or any other framework all work identically, and `-- args` are forwarded to every test binary (e.g. `-- --gtest_filter=...`). Note: synthesized test target names may contain `/` (`tests/00-a/0.cpp` → `00-a/0`), unlike `[targets.*]` names — the two namespaces are intentionally separate (test targets never enter the manifest or publishing). Tests are named by their `tests/`-relative path (`tests/00-a/0.cpp` → `00-a/0`), each test compiles in isolation (a broken test fails alone; package/dep breakage is reported as a build error instead), and `mcpp test ` / `--message-format json` filter and machine-format the run. +**The package being built stands for itself in its own graph** (2026.11.9.1+). +A dependency reached through the package's `[dev-dependencies]` may name the +package being built: a library tested with a framework that itself uses the +library (`mcpplibs.os` under mtest). The +edge is the package itself, whatever version it asked for, and a version it did +not ask for is said; a second copy would provide every module of the package +twice in one program, and what is under test is the package as it is here. The +root's `[dev-dependencies]` serve its tests only, so the framework reaches the +library without a cycle of packages. + ## `[build-dependencies]` — Build-Time Dependencies (mcpp 2026.8.29.1+) ```toml diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 82ea843b8..2f0ed0de6 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -407,6 +407,11 @@ gtest = "1.15.2" 错误),`mcpp test ` 与 `--message-format json` 分别提供过滤与 机器可读输出。 +**正在构建的包在其自身的依赖图中代表它自己**(2026.11.9.1+)。经包的 `[dev-dependencies]` 到达的依赖可以指名正在构建的包:用一个本身使用 +该库的测试框架测试该库(mtest 下的 `mcpplibs.os`)。这条边即该包自身,无论它要求哪个版本,未要求的版本会被说明; +第二份副本会在一个程序中把包的每个模块提供两次,而受测的是此处的这个包。根的 `[dev-dependencies]` 只服务它的测试, +因此框架到达该库而不形成包的环。 + ## `[build-dependencies]` —— 构建期依赖(mcpp 2026.8.29.1+) ```toml diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index d41164c53..346df6946 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -824,6 +824,28 @@ step4b_resolve_identity(PrepareState& state, WorklistItemCtx& ctx) { return {}; } +// THE PACKAGE BEING BUILT STANDS FOR ITSELF IN ITS TESTS' GRAPH. A dependency +// reached through its `[dev-dependencies]` may name it: a library tested with a +// framework that itself uses the library (mcpplibs.os under mtest). A second +// copy from the index would provide every module of the package twice in one +// program, and what is under test is the package as it is here; so the edge is +// the root's, whatever version it asked for, and a version it did not ask for +// is said. Outside the tests' graph such an edge is a cycle, refused as one. +static bool step4b_names_the_root(PrepareState& state, const WorklistItemCtx& ctx) { + const auto& item = ctx.item; + const auto& root = state.m->package; + if (!item.devOnly || item.consumerDepIndex == kMainConsumer || root.virtualRoot || !ctx.key.variant.empty() + || ctx.key.shortName != root.name || ctx.key.ns != root.namespace_) + return false; + recordDependencyEdge(state, item.consumerDepIndex, 0, item.spec, item.fromBuildTable, item.name); + if (!item.originalConstraint.empty() && item.originalConstraint != root.version + && item.originalConstraint != "*") + mcpp::ui::note(std::format( + "{} asks for {} {}; it is the package being built, {}, which stands for itself", + item.requestedBy, state.qualifiedKey(ctx.key), item.originalConstraint, root.version)); + return true; +} + static std::expected step4b_identity_version_merge(PrepareState& state, WorklistItemCtx& ctx, std::map::iterator it) { @@ -2101,11 +2123,15 @@ static std::expected step4b_cycle_check(PrepareState& state) // IS RESOLVED (#649 E6). The build-cache key walk was the only reader that // noticed, and it runs for the global cache only, so the same manifest was // refused by default and built under `--cache=local`. Every edge counts, - // build-only ones included, as the key walk counts them. + // build-only ones included, as the key walk counts them -- but the root's + // own `[dev-dependencies]`: they serve its tests, which no package + // depends on, so a framework that uses the package under test reaches its + // library and not back through the tests (step4b_names_the_root). { mcpp::graph::AdjacencyList deps(state.packages.size()); for (auto const& e : state.dependencyEdges) { if (e.consumerPackageIndex >= deps.size()) continue; + if (e.consumerPackageIndex == 0 && e.devTable) continue; if (e.dependencyPackageIndex >= state.packages.size()) continue; deps[e.consumerPackageIndex].push_back(e.dependencyPackageIndex); } @@ -2439,6 +2465,7 @@ std::expected phase4b_graph_worklist(PrepareState& state) { if (auto r = step4b_resolve_identity(state, ctx); !r) return std::unexpected(r.error()); + if (step4b_names_the_root(state, ctx)) continue; if (auto it = state.resolved.find(ctx.key); it != state.resolved.end()) { if (auto r = step4b_handle_already_resolved(state, ctx, it); !r) diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 29bb63933..2279523b7 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -1728,9 +1728,15 @@ static std::expected step13_dependency_cache(PrepareState& st // below walks `mcpp::graph::topological_order` — dependencies before // dependents — instead of recursing, with an explicit cycle check up // front standing in for the old in-progress guard. + // The root's own `[dev-dependencies]` serve its tests, not its + // library, which a framework that uses the package under test reaches + // (step4b_names_the_root): the root's key does not fold them in. + auto serves_root_tests = [](const auto& e) { + return e.consumerPackageIndex == 0 && e.devTable; + }; mcpp::graph::AdjacencyList pkgDeps(state.packages.size()); for (auto& e : state.dependencyEdges) - if (e.consumerPackageIndex < pkgDeps.size()) + if (e.consumerPackageIndex < pkgDeps.size() && !serves_root_tests(e)) pkgDeps[e.consumerPackageIndex].push_back(e.dependencyPackageIndex); // An import a declared edge already covers adds nothing. One that a // declared path runs the other way is a module this build generates @@ -1796,7 +1802,7 @@ static std::expected step13_dependency_cache(PrepareState& st && state.dep_cache_identities[idx - 1].sourceKind == "version"; if (!selfIsIndex) localTaint[idx] = 1; for (auto& e : state.dependencyEdges) { - if (e.consumerPackageIndex != idx) continue; + if (e.consumerPackageIndex != idx || serves_root_tests(e)) continue; // `idx`'s dependencies precede it in `keyOrder`, so their // keys and taint are already folded in below. auto const& up = pkgKeys[e.dependencyPackageIndex]; diff --git a/tests/e2e/1052_a_framework_that_uses_the_package_under_test_is_built_against_it.sh b/tests/e2e/1052_a_framework_that_uses_the_package_under_test_is_built_against_it.sh new file mode 100755 index 000000000..3151f4e8e --- /dev/null +++ b/tests/e2e/1052_a_framework_that_uses_the_package_under_test_is_built_against_it.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# requires: gcc +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" +# 1052 -- a test framework that itself uses the package under test is built against that package. +# +# mcpplibs.os is tested with mtest, and mtest uses mcpplibs.os. A second copy of the package from +# the framework's own edge would provide every module of the package twice in one program; the +# package being built stands for itself in its own graph (docs/05), whatever version the edge asked +# for, and its tests reach the framework without a cycle of packages. +# +# Criteria: the root's test, written with a framework whose library imports the root's module, +# builds and passes; the root's module is compiled once; a version the edge did not ask for is said. +set -e +t=$(mktemp -d); trap 'rm -rf "$t"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | tail -20; exit 1; } +mkdir -p "$t/core/src" "$t/core/tests" "$t/tf/src" +cat > "$t/core/mcpp.toml" <<'EOF' +[package] +namespace = "acme" +name = "core" +version = "0.2.0" + +[targets.core] +kind = "lib" + +[dev-dependencies] +acme.tf = { path = "../tf" } +EOF +cat > "$t/core/src/core.cppm" <<'EOF' +export module acme.core; +export int answer() { return 42; } +EOF +cat > "$t/core/tests/uses_tf.cpp" <<'EOF' +import acme.core; +import acme.tf; +int main() { return tf::check(answer() == 42) ? 0 : 1; } +EOF +cat > "$t/tf/mcpp.toml" <<'EOF' +[package] +namespace = "acme" +name = "tf" +version = "0.1.0" + +[targets.tf] +kind = "lib" + +[dependencies] +acme.core = { path = "../core", version = "0.1.0" } +EOF +cat > "$t/tf/src/tf.cppm" <<'EOF' +export module acme.tf; +import acme.core; +export namespace tf { bool check(bool held) { return held && answer() == 42; } } +EOF +cd "$t/core" +out=$("$MCPP" test 2>&1) || fail "the test of the package under its own framework failed" "$out" +grep -q 'uses_tf \.\.\. ok' <<<"$out" || fail "the test did not pass" "$out" +grep -q 'it is the package being built, 0.2.0, which stands for itself' <<<"$out" \ + || fail "the version the edge did not ask for is not said" "$out" +n=$(find target -name 'core.m.o' -o -name 'core.o' | grep -c 'acme_core\|/core' || true) +[ "$n" -le 1 ] || fail "the root's module was compiled more than once ($n)" "$(find target -name 'core*.o')" +echo " ok the framework is built against the package under test, and the test passes" +echo "PASS: 1052 a framework that uses the package under test is built against it" From c382ae08ff19a5fe6d9f412463b5d5608809183d Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 13:42:48 +0900 Subject: [PATCH 30/45] test: a pattern names a part of a program as the run names it; the Windows ACP check selects its cases by id - `mcpp test ` matches a program or a part of one run as a process per file of its cases (`unit/test_home`): one rule (test_matches) for the plan's tests, a workspace's members and an archive. - check_windows_home_legacy.py runs its two cases with --case and reads mtest's lines. - The self-standing rule of the tests' graph lives in graph_edges.cpp (graph.cpp within its length). Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/tools/check_windows_home_legacy.py | 11 +++--- CHANGELOG.md | 3 +- docs/08-testing.md | 3 +- docs/zh/08-testing.md | 3 +- src/build/execute.cppm | 39 +++++++++++++------ src/build/prepare/graph.cpp | 26 +------------ src/build/prepare/graph_edges.cpp | 23 +++++++++++ src/build/prepare/plan.cpp | 2 +- src/build/prepare/state.cppm | 4 ++ ...irectory_and_the_tests_a_change_affects.sh | 4 ++ 10 files changed, 73 insertions(+), 45 deletions(-) diff --git a/.github/tools/check_windows_home_legacy.py b/.github/tools/check_windows_home_legacy.py index 3e9b11052..cdcbe05f9 100644 --- a/.github/tools/check_windows_home_legacy.py +++ b/.github/tools/check_windows_home_legacy.py @@ -14,22 +14,23 @@ assert original.count(marker) == 1, 'identify the test-only code-page setting explicitly' -def run_case(pattern, passed_case): - result = subprocess.run([os.environ['MCPP'], '--offline', 'test', pattern], cwd=root, +def run_case(passed_case): + # The case by its identifier (mtest's): it runs in the process of its file. + result = subprocess.run([os.environ['MCPP'], '--offline', 'test', '--case', passed_case], cwd=root, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=600) sys.stdout.write(result.stdout) sys.stderr.write(result.stderr) assert result.returncode == 0, result.returncode output = re.sub(r'\x1b\[[0-9;]*m', '', result.stdout + result.stderr) - assert '[ OK ] ' + passed_case in output, \ + assert re.search(r'^\s*ok\s+' + re.escape(passed_case) + r'\s*$', output, re.M), \ 'the legacy ACP case did not pass; a UTF-8-only skip is not proof' try: manifest.write_bytes(original.replace(marker, b'windows_code_page = "legacy"', 1)) - run_case('home', 'HomeSelection.UnicodeLocatorRoundTripDoesNotNarrowTheSelectedPath') - run_case('bmi_cache', 'BMICacheMaintenance.RunnerCachePathDoesNotUseTheWindowsAnsiCodePage') + run_case('HomeSelection/UnicodeLocatorRoundTripDoesNotNarrowTheSelectedPath') + run_case('BMICacheMaintenance/RunnerCachePathDoesNotUseTheWindowsAnsiCodePage') finally: manifest.write_bytes(original) print('PASS: home and runner-cache paths ran under legacy Windows ACP; manifest restored') diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e7589d9..1073333f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,7 +59,8 @@ `mtest report`, `analyze` and `graph`. The tests of an archive run by the same runner (`mcpp test --archive`): their protocol, recorded in `mcpp-tests.json`, a process per file of their cases, the workers and the - store. mcpp's own unit tests are one program per directory: 166 + store. A pattern names a part as the run names it (`mcpp test unit/test_home`). + mcpp's own unit tests are one program per directory: 166 programs of 32 MB became one of 37 MB, run as 166 processes. - clang pairs with the MinGW-w64 payload for `x86_64-windows-gnu`: the payload is clang's sysroot and libstdc++ its C++ library, `import std` included. diff --git a/docs/08-testing.md b/docs/08-testing.md index d44a2548a..b046d20da 100644 --- a/docs/08-testing.md +++ b/docs/08-testing.md @@ -72,7 +72,8 @@ cases** (2026.11.9.1+): mcpp lists the cases of a program of several files, and each file's run selects its own (P4) and is scheduled across the workers as a program is, named `/` -- the isolation and the parallelism of a program per file, with one link. `[test] split = "case"` gives each case its own process, and -`"none"` runs the program once. +`"none"` runs the program once. A pattern names a program or a part of one as +the run names it: `mcpp test unit/test_home` (or `home`) runs that file's part. A test program keeps what it reaches (2026.11.9.1+): units are compiled with one section per function and per object, and a test links with `--gc-sections` diff --git a/docs/zh/08-testing.md b/docs/zh/08-testing.md index 61133c305..6038bebe6 100644 --- a/docs/zh/08-testing.md +++ b/docs/zh/08-testing.md @@ -61,7 +61,8 @@ discover = ["checks/**/*.cpp", "!checks/fixtures/**"] 使用测试协议的程序**按其用例所在文件各起一个进程**运行(2026.11.9.1+):mcpp 先列出多文件程序的用例, 每个文件的那次运行只选取自己的用例(P4),像一个程序一样在各工作线程间调度,名为 `<程序>/<文件>`——兼得每文件一个程序的隔离与并行,而只链接一次。`[test] split = "case"` -使每个用例一个进程,`"none"` 只运行一次。 +使每个用例一个进程,`"none"` 只运行一次。模式按运行所用的名字指名程序或其一部分:`mcpp test unit/test_home`(或 `home`) +运行该文件的那一部分。 测试程序只保留它用到的部分(2026.11.9.1+):各单元以每函数、每数据对象一个节编译, 测试以 `--gc-sections` 链接(Mach-O 为 `-dead_strip`),测某一模块的测试不再带上整个包 diff --git a/src/build/execute.cppm b/src/build/execute.cppm index f74606e2b..6f918a707 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -4283,6 +4283,15 @@ static void print_test_guide(const BuildContext& ctx, bool json) { // backend that drives it, how long planning and the build took, and how the // test binaries are executed. One per configuration group of a `mcpp test` // over several members; `run_tests` holds one for its one member. +// WHETHER A PATTERN NAMES A TEST: its program, or a part of one run as a +// process per file of its cases (`/'), as the run names them. +static bool test_matches(const LinkUnit& lu, std::string_view filter) { + if (filter.empty() || lu.targetName.find(filter) != std::string::npos) return true; + return std::ranges::any_of(lu.ownSources, [&](const std::filesystem::path& f) { + return std::format("{}/{}", lu.targetName, f.stem().generic_string()).find(filter) != std::string::npos; + }); +} + // A TEST PROGRAM TO RUN: built by this command, or read from an archive. struct TestRunnable { std::string name; @@ -4708,8 +4717,7 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, return lu.kind == mcpp::build::LinkUnit::TestBinary && (owner.empty() || lu.memberOf == owner) && (!tb.affected || tb.affected->contains(lu.targetName)) - && (testOpts.filter.empty() - || lu.targetName.find(testOpts.filter) != std::string::npos); + && test_matches(lu, testOpts.filter); }; std::vector results; auto emit_json = [&](const TestResult& r) { @@ -5397,11 +5405,14 @@ static int test_run_member(TestBuild& tb, const TestOptions& testOpts, // The largest first, so the longest work starts first. std::ranges::stable_sort(ordered, std::greater{}, [](auto const& g) { return g.second.size(); }); std::size_t n = 0; + // A pattern that named a part, not its program, keeps that part. + const bool whole = testOpts.filter.empty() || r.name.find(testOpts.filter) != std::string::npos; for (auto& [key, ids] : ordered) { Runnable part = r; const auto label = split == "case" ? key : std::filesystem::path(key).stem().generic_string(); part.name = std::format("{}/{}", r.name, label); + if (!whole && part.name.find(testOpts.filter) == std::string::npos) continue; part.protocolDir = r.protocolDir / "split" / std::to_string(n++); std::error_code ec; std::filesystem::create_directories(part.protocolDir, ec); @@ -5554,7 +5565,7 @@ int write_test_archive(const TestOptions& o, std::span legs) { leg.triple = l.triple; for (auto const& lu : l.ctx->plan.linkUnits) { if (lu.kind != LinkUnit::TestBinary) continue; - if (!o.filter.empty() && lu.targetName.find(o.filter) == std::string::npos) continue; + if (!test_matches(lu, o.filter)) continue; const auto exe = l.ctx->outputDir / lu.output; if (!fs::is_regular_file(exe, ec)) continue; ta::Test t{lu.targetName, ta::test_dest(l.triple, lu.targetName, exe.filename().string()), {}, @@ -5882,8 +5893,7 @@ export int run_tests(std::span passthrough, // filtered run must not clobber it down to one entry). auto filter_match = [&](const mcpp::build::LinkUnit& lu) { return lu.kind == mcpp::build::LinkUnit::TestBinary - && (testOpts.filter.empty() - || lu.targetName.find(testOpts.filter) != std::string::npos); + && test_matches(lu, testOpts.filter); }; if (!testOpts.filter.empty()) { bool any = false; @@ -6053,8 +6063,7 @@ export int run_test_legs(std::span passthrough, auto filter_match = [&](const mcpp::build::LinkUnit& lu) { return lu.kind == mcpp::build::LinkUnit::TestBinary - && (testOpts.filter.empty() - || lu.targetName.find(testOpts.filter) != std::string::npos); + && test_matches(lu, testOpts.filter); }; // ── built at the same time, a thread per leg ── @@ -6225,9 +6234,16 @@ export void run_workspace_tests(std::span passthrough, indexOf[members[i].path] = i; if (!members[i].error.empty()) { slots[i].error = members[i].error; continue; } if (members[i].targets.empty()) { slots[i].note = members[i].noTests; continue; } - if (!testOpts.filter.empty() - && std::ranges::none_of(members[i].targets, [&](auto const& t) { - return t.name.find(testOpts.filter) != std::string::npos; })) { + // As `test_matches' asks of a planned test: the program, or a part of it. + auto names_it = [&](const mcpp::manifest::Target& t) { + if (t.name.find(testOpts.filter) != std::string::npos) return true; + auto part = [&](const std::string& f) { + return std::format("{}/{}", t.name, std::filesystem::path(f).stem().generic_string()) + .find(testOpts.filter) != std::string::npos; + }; + return part(t.main) || std::ranges::any_of(t.testSources, part); + }; + if (!testOpts.filter.empty() && std::ranges::none_of(members[i].targets, names_it)) { slots[i].noMatch = true; continue; } @@ -6420,8 +6436,7 @@ export void run_workspace_tests(std::span passthrough, test_bulk(tb, testOpts, [&](const mcpp::build::LinkUnit& lu) { return lu.kind == mcpp::build::LinkUnit::TestBinary && runnable.contains(lu.memberOf) - && (testOpts.filter.empty() - || lu.targetName.find(testOpts.filter) != std::string::npos); + && test_matches(lu, testOpts.filter); }); if (!runnable.empty()) test_resolve_runner(tb, testOpts, json); } diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index 346df6946..3546c3306 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -824,28 +824,6 @@ step4b_resolve_identity(PrepareState& state, WorklistItemCtx& ctx) { return {}; } -// THE PACKAGE BEING BUILT STANDS FOR ITSELF IN ITS TESTS' GRAPH. A dependency -// reached through its `[dev-dependencies]` may name it: a library tested with a -// framework that itself uses the library (mcpplibs.os under mtest). A second -// copy from the index would provide every module of the package twice in one -// program, and what is under test is the package as it is here; so the edge is -// the root's, whatever version it asked for, and a version it did not ask for -// is said. Outside the tests' graph such an edge is a cycle, refused as one. -static bool step4b_names_the_root(PrepareState& state, const WorklistItemCtx& ctx) { - const auto& item = ctx.item; - const auto& root = state.m->package; - if (!item.devOnly || item.consumerDepIndex == kMainConsumer || root.virtualRoot || !ctx.key.variant.empty() - || ctx.key.shortName != root.name || ctx.key.ns != root.namespace_) - return false; - recordDependencyEdge(state, item.consumerDepIndex, 0, item.spec, item.fromBuildTable, item.name); - if (!item.originalConstraint.empty() && item.originalConstraint != root.version - && item.originalConstraint != "*") - mcpp::ui::note(std::format( - "{} asks for {} {}; it is the package being built, {}, which stands for itself", - item.requestedBy, state.qualifiedKey(ctx.key), item.originalConstraint, root.version)); - return true; -} - static std::expected step4b_identity_version_merge(PrepareState& state, WorklistItemCtx& ctx, std::map::iterator it) { @@ -2126,7 +2104,7 @@ static std::expected step4b_cycle_check(PrepareState& state) // build-only ones included, as the key walk counts them -- but the root's // own `[dev-dependencies]`: they serve its tests, which no package // depends on, so a framework that uses the package under test reaches its - // library and not back through the tests (step4b_names_the_root). + // library and not back through the tests (names_the_root, graph_edges.cpp). { mcpp::graph::AdjacencyList deps(state.packages.size()); for (auto const& e : state.dependencyEdges) { @@ -2465,7 +2443,7 @@ std::expected phase4b_graph_worklist(PrepareState& state) { if (auto r = step4b_resolve_identity(state, ctx); !r) return std::unexpected(r.error()); - if (step4b_names_the_root(state, ctx)) continue; + if (names_the_root(state, ctx.item, ctx.key)) continue; if (auto it = state.resolved.find(ctx.key); it != state.resolved.end()) { if (auto r = step4b_handle_already_resolved(state, ctx, it); !r) diff --git a/src/build/prepare/graph_edges.cpp b/src/build/prepare/graph_edges.cpp index 71c7dcd98..85deca7bd 100644 --- a/src/build/prepare/graph_edges.cpp +++ b/src/build/prepare/graph_edges.cpp @@ -10,6 +10,7 @@ import std; import mcpp.manifest; import mcpp.modgraph.graph; import mcpp.pm.dep_spec; +import mcpp.ui; namespace mcpp::build { @@ -136,6 +137,28 @@ void recordDependencyEdge( }); } +// THE PACKAGE BEING BUILT STANDS FOR ITSELF IN ITS TESTS' GRAPH. A dependency +// reached through its `[dev-dependencies]` may name it: a library tested with a +// framework that itself uses the library (mcpplibs.os under mtest). A second +// copy from the index would provide every module of the package twice in one +// program, and what is under test is the package as it is here; so the edge is +// the root's, whatever version it asked for, and a version it did not ask for +// is said. Outside the tests' graph such an edge is a cycle, refused as one. +bool names_the_root(PrepareState& state, const WorkItem& item, const ResolvedKey& key) { + const auto& root = state.m->package; + if (!item.devOnly || item.consumerDepIndex == kMainConsumer || root.virtualRoot || !key.variant.empty() + || key.shortName != root.name || key.ns != root.namespace_) + return false; + recordDependencyEdge(state, item.consumerDepIndex, 0, item.spec, item.fromBuildTable, item.name); + if (!item.originalConstraint.empty() && item.originalConstraint != root.version + && item.originalConstraint != "*") + mcpp::ui::note(std::format( + "{} asks for {} {}; it is the package being built, {}, which stands for itself", + item.requestedBy, state.qualifiedKey(key), item.originalConstraint, root.version)); + return true; +} + + // The usage requirements every edge carries to its consumer: a dependency's // public usage reaches the consumer's own units (Private, Public), its users // (Public, Interface), or, on a dev edge, the consumer's tests only. A fixpoint diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 2279523b7..bf93a4f4a 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -1730,7 +1730,7 @@ static std::expected step13_dependency_cache(PrepareState& st // front standing in for the old in-progress guard. // The root's own `[dev-dependencies]` serve its tests, not its // library, which a framework that uses the package under test reaches - // (step4b_names_the_root): the root's key does not fold them in. + // (names_the_root, graph_edges.cpp): the root's key does not fold them in. auto serves_root_tests = [](const auto& e) { return e.consumerPackageIndex == 0 && e.devTable; }; diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index 5d927783c..9cf49d003 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -1013,6 +1013,10 @@ void recordDependencyEdge(PrepareState& state, std::size_t consumerDepIndex, std::size_t dependencyPackageIndex, const mcpp::manifest::DependencySpec& spec, bool buildTable, const std::string& writtenKey); +// graph_edges.cpp: the package being built stands for itself in its tests' +// graph -- an edge reached through its `[dev-dependencies]` naming it is its +// own, recorded here; true when the request is so answered. +bool names_the_root(PrepareState& state, const WorkItem& item, const ResolvedKey& key); // graph_edges.cpp: defines `PrepareState::computeUsageRequirements`. void define_usage_requirements(PrepareState& state); std::expected phase4b_graph_worklist(PrepareState& state); diff --git a/tests/e2e/1038_test_programs_of_a_directory_and_the_tests_a_change_affects.sh b/tests/e2e/1038_test_programs_of_a_directory_and_the_tests_a_change_affects.sh index 1c084387d..d40766bf9 100644 --- a/tests/e2e/1038_test_programs_of_a_directory_and_the_tests_a_change_affects.sh +++ b/tests/e2e/1038_test_programs_of_a_directory_and_the_tests_a_change_affects.sh @@ -54,6 +54,10 @@ printf '\n[test]\nprograms = "directory"\n' >> mcpp.toml # A program that speaks the protocol runs as a process per file of its cases (`split = "file"`). grep -q 'unit/a \.\.\. ok (.*1 case)' a.log && grep -q 'unit/b \.\.\. ok (.*1 case)' a.log \ || fail "A: unit does not run as a process per file" a.log +# A pattern names a part as the run names it. +"$MCPP" test unit/b > a-part.log 2>&1 || fail "A: a part named by its pattern" a-part.log +grep -q 'unit/b \.\.\. ok' a-part.log && ! grep -q 'unit/a \.\.\.' a-part.log \ + || fail "A: the pattern of a part did not run that part alone" a-part.log printf 'split = "none"\n' >> mcpp.toml "$MCPP" test > a.log 2>&1 || fail "A: the directory programs, run once" a.log grep -q 'unit \.\.\. ok (.*2 cases)' a.log || fail "A: unit is not one program of two cases" a.log From 2fc69da8c2493915f76b16f1a3cac3f34bf94449 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 14:26:40 +0900 Subject: [PATCH 31/45] manifest: a provider's rows as a sorted vector (two loop levels; mcpp#905's GCC defect left a set's constructor undefined) Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- modules/manifest/src/platform_providers.cppm | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/modules/manifest/src/platform_providers.cppm b/modules/manifest/src/platform_providers.cppm index f835d63b3..691bc147a 100644 --- a/modules/manifest/src/platform_providers.cppm +++ b/modules/manifest/src/platform_providers.cppm @@ -98,14 +98,21 @@ std::vector stated_providers(const Statement& s, std::string_vie return out; } +// The rows one provider serves, each once. A sorted vector and not a set: GCC +// 16.1 left a set's constructor that this unit instantiated undefined for an +// importer (mcpp#905). +std::vector rows_served(const Statement& s, const StatedProvider& p) { + std::vector rows; + for (auto const& pattern : p.serves) std::ranges::copy(rows_matching(s, pattern), std::back_inserter(rows)); + std::ranges::sort(rows); + rows.erase(std::unique(rows.begin(), rows.end()), rows.end()); + return rows; +} + std::vector served_identities(const Statement& s, std::string_view name) { std::vector out; - for (auto const& p : stated_providers(s, name)) { - std::set seen; - for (auto const& pattern : p.serves) - for (auto& id : rows_matching(s, pattern)) - if (seen.insert(id).second) out.push_back({ std::move(id), p.name, p.priority }); - } + for (auto const& p : stated_providers(s, name)) + for (auto const& id : rows_served(s, p)) out.push_back({ id, p.name, p.priority }); std::ranges::sort(out, [](auto const& a, auto const& b) { return std::tie(a.identity, b.priority, a.provider) < std::tie(b.identity, a.priority, b.provider); }); From 82e4201eb04ebd636fbf4e81187b477ac6813007 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 14:27:12 +0900 Subject: [PATCH 32/45] =?UTF-8?q?docs(design):=20the=20round's=20later=20d?= =?UTF-8?q?ecisions=20in=20=C2=A719?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .agents/docs/2026-11-09-target-model-design.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.agents/docs/2026-11-09-target-model-design.md b/.agents/docs/2026-11-09-target-model-design.md index 63920af62..21f8f035d 100644 --- a/.agents/docs/2026-11-09-target-model-design.md +++ b/.agents/docs/2026-11-09-target-model-design.md @@ -402,3 +402,11 @@ mcpp 的单元测试(`tests/unit/` 166 个文件,以及各成员的 `modules | G8 `mcpp patch edit/save` | 不采纳 | 核心保留的是补丁契约(`patches`、包根、`patch_root`);编辑与生成是通用工具的事:`mcpp why deps` 报告包根,`git diff` 写出补丁,无功能缺口 | | `mcpp check platform` | 不采纳,并入清单读取与 `mcpp why platform` | 一级子命令须有独立的场景;平台语句的校验在读取清单时进行,作者与使用者在构建中即得到;所服务的身份属来源解释,与 `why target`、`why flags` 同族 | | 测试程序 | 按目录组成为可选(`[test] programs`);`[test] split` 按文件分进程为缺省;测试只保留可达部分(`--gc-sections`) | 缺省不改变既有工程的程序划分与命名;按文件分进程同时保留隔离与并行 | +| 归档的测试 | 由 `mcpp test` 自身的运行器运行(协议、按文件分进程、工作线程、存储) | 一份归档与一次构建的测试是同一批程序,报告不应因运行方式而异;清单记录构建时判定的协议 | +| 测试的模式 | 匹配程序名,或运行所示的部分名(`unit/test_home`) | 用户看到什么名字就能用什么名字选择;规则只有一处(`test_matches`) | +| 链接丢掉的协议标记 | 从链接所用的目标文件读取 | 0.5.1 之前的 mtest 与 gtest 适配器的标记无人引用;默认链接方式改变后既有工程照常被识别(无感升级) | +| 被构建的包出现在自身测试图中 | 由该包自身代表(仅经 `[dev-dependencies]` 到达的边) | 测试框架本身使用被测库(mtest 与 mcpplibs.os)时,第二份副本会让每个模块在一个程序中出现两次;库自身依赖图中的回边仍是环并被拒绝 | +| 版本要求与根的路径钉 | 维持拒绝 | 版本要求是对图的承诺;改为由 mtest 声明 `^0.4.1`、os 以 0.4.2 发布(增量 API 在 0.4 内兼容) | +| 交叉目标的 `[target.] toolchain` | 构建程序取被它替换的规格 | 与行的 pin 同一规则;该段名的是目标的编译器 | +| mcppls 的自启动测试 | mtest `child` 对象;需独立缓存的两个测试放在 `tests/isolated/` | mcpplibs.os 不修改进程自身环境,隔离缓存只能由新进程获得 | +| openkal-run 的实现 | 能力槽加按系统默认(D2,openkal-image 0.5.4) | 图或平台已有的实现优先,FreeBSD 等新系统无需修补该包 | From 8040480412edcd665552460eadd7c5078b8b32f4 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 14:51:53 +0900 Subject: [PATCH 33/45] ci: a release asset whose web address fails is fetched by the API; the cached xlings and mtest are not downloaded again GitHub's web address of a release asset answered 504 for minutes while the API's address of the same asset answered 200 (2026-11-09). - fetch_release.sh follows a failed web attempt with the API's, with the job's token. - setup-macos-llvm downloaded the xlings release on every run, a cache hit or not; it now skips the download when the cached xlings is the pinned one, as bootstrap-mcpp does. - e2e-mtest keeps the mtest bundle in the cache, by version, and fetches it through fetch_release.sh; the bundle is mtest 0.5.2. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/actions/bootstrap-mcpp/action.yml | 2 ++ .github/actions/e2e-mtest/action.yml | 23 +++++++++++++--- .github/actions/setup-macos-llvm/action.yml | 28 +++++++++++++------- .github/tools/fetch_release.sh | 29 ++++++++++++++++++++- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 5d6b49e1c..24c11601e 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -107,6 +107,7 @@ runs: env: XLINGS_NON_INTERACTIVE: '1' XLINGS_VERSION: ${{ inputs.xlings-version }} + GITHUB_TOKEN: ${{ github.token }} run: | REPO_DIR="$(pwd)" # Always install the pinned version — the cache may hold an older @@ -231,6 +232,7 @@ runs: env: XLINGS_NON_INTERACTIVE: '1' XLINGS_VERSION: ${{ inputs.xlings-version }} + GITHUB_TOKEN: ${{ github.token }} run: | REPO_DIR="$(pwd)" # FAST PATH: see the unix leg for the reasoning. The cost on Windows is diff --git a/.github/actions/e2e-mtest/action.yml b/.github/actions/e2e-mtest/action.yml index 4a52676c7..03836d021 100644 --- a/.github/actions/e2e-mtest/action.yml +++ b/.github/actions/e2e-mtest/action.yml @@ -14,11 +14,11 @@ inputs: version: description: the mtest release required: false - default: '0.4.0' + default: '0.5.2' sha256: description: sha256 of mtest--openkal-bundle-all.tar.gz required: false - default: 'b62e4ac7cd76b20dbdc48a5d041d377444e8815ee7a4ff1e7686145d13acc925' + default: 'be443f8c546b3f4628dcd3f1eb6f45b4d698a7f805232f966349faf8250863c6' token: description: a token that reads this repository's actions required: false @@ -27,18 +27,33 @@ inputs: runs: using: composite steps: + # The bundle is a release's fixed bytes: kept once per version, so that a + # job downloads it only when no job before it did. + - name: The mtest bundle, kept + id: bundle + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/mtest-bundle + key: mtest-bundle-${{ inputs.version }}-${{ inputs.sha256 }} - name: mtest shell: bash env: MTEST_VERSION: ${{ inputs.version }} MTEST_SHA256: ${{ inputs.sha256 }} + GITHUB_TOKEN: ${{ inputs.token }} run: | set -euo pipefail archive="mtest-$MTEST_VERSION-openkal-bundle-all.tar.gz" temp="$RUNNER_TEMP"; python=python3 if [ "$RUNNER_OS" = Windows ]; then temp=$(cygpath -u "$RUNNER_TEMP"); python=python; fi - curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o "$temp/$archive" \ - "https://github.com/mcpp-community/mtest/releases/download/v$MTEST_VERSION/$archive" + mkdir -p "$temp/mtest-bundle" + if [ ! -s "$temp/mtest-bundle/$archive" ]; then + # Retried, and by the API's address when the web's fails (fetch_release.sh). + bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ + "https://github.com/mcpp-community/mtest/releases/download/v$MTEST_VERSION/$archive" \ + "$temp/mtest-bundle/$archive" + fi + cp "$temp/mtest-bundle/$archive" "$temp/$archive" actual=$("$python" -c "import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$temp/$archive") [ "$actual" = "$MTEST_SHA256" ] || { echo "::error::$archive: sha256 $actual, the action pins $MTEST_SHA256"; exit 1; } mkdir -p "$temp/mtest" && tar xzf "$temp/$archive" -C "$temp/mtest" diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index 0c6336922..12ec05e2b 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -51,17 +51,25 @@ runs: env: XLINGS_NON_INTERACTIVE: '1' XLINGS_VERSION: ${{ inputs.xlings-version }} + GITHUB_TOKEN: ${{ github.token }} run: | - WORK=$(mktemp -d) - tarball="xlings-${XLINGS_VERSION}-macosx-arm64.tar.gz" - # Retried and verified — .github/tools/fetch_release.sh. A bare curl - # here is the `curl: (52) Empty reply from server` flake. - bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ - "https://github.com/d2learn/xlings/releases/download/v${XLINGS_VERSION}/${tarball}" \ - "${WORK}/${tarball}" - tar -xzf "${WORK}/${tarball}" -C "${WORK}" - XLINGS_DIR="${WORK}/xlings-${XLINGS_VERSION}-macosx-arm64" - "$XLINGS_DIR/subos/default/bin/xlings" self install + # THE CACHE ALREADY HOLDS THE PINNED XLINGS (its key names the version): + # nothing is downloaded, as bootstrap-mcpp's fast path does. This step + # fetched the release on every run, a cache hit or not, and a 504 of + # that fetch failed the job (2026-11-09). + XL="$HOME/.xlings/subos/default/bin/xlings" + if ! { [ -x "$XL" ] && "$XL" --version 2>/dev/null | head -1 | grep -qF "$XLINGS_VERSION"; }; then + WORK=$(mktemp -d) + tarball="xlings-${XLINGS_VERSION}-macosx-arm64.tar.gz" + # Retried and verified, and by the API's address when the web's + # fails — .github/tools/fetch_release.sh. + bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ + "https://github.com/d2learn/xlings/releases/download/v${XLINGS_VERSION}/${tarball}" \ + "${WORK}/${tarball}" + tar -xzf "${WORK}/${tarball}" -C "${WORK}" + XLINGS_DIR="${WORK}/xlings-${XLINGS_VERSION}-macosx-arm64" + "$XLINGS_DIR/subos/default/bin/xlings" self install + fi export PATH="$HOME/.xlings/subos/default/bin:$PATH" xlings --version echo "PATH=$HOME/.xlings/subos/default/bin:$PATH" >> "$GITHUB_ENV" diff --git a/.github/tools/fetch_release.sh b/.github/tools/fetch_release.sh index 2478d6f61..33e71e7a3 100755 --- a/.github/tools/fetch_release.sh +++ b/.github/tools/fetch_release.sh @@ -45,6 +45,33 @@ verify() { esac } +# THE SAME FILE BY GITHUB'S API. The web address of a release asset +# (`github.com///releases/download//`) answered 504 for +# minutes at a time while the API's address of the same asset answered 200 +# (measured 2026-11-09 on xlings-2026.10.26.1-macosx-arm64.tar.gz, three times +# each). A failed web attempt is followed by the API's, with the job's token +# where there is one (the API's anonymous limit is 60 requests an hour). +api_fetch() { + local rest="${url#https://github.com/}" + [ "$rest" != "$url" ] || return 1 + local owner repo releases download tag name + IFS=/ read -r owner repo releases download tag name <<<"$rest" + [ "$releases/$download" = "releases/download" ] && [ -n "$name" ] || return 1 + local auth=() + local token="${GITHUB_TOKEN:-${GH_TOKEN:-}}" + [ -z "$token" ] || auth=(-H "Authorization: Bearer $token") + local id + id=$(curl -fsSL --retry 3 --retry-all-errors --connect-timeout 20 --max-time 60 "${auth[@]}" \ + "https://api.github.com/repos/$owner/$repo/releases/tags/$tag" \ + | python3 -c 'import json,sys; n=sys.argv[1]; print(next((str(a["id"]) for a in json.load(sys.stdin)["assets"] if a["name"] == n), ""))' "$name") \ + || return 1 + [ -n "$id" ] || return 1 + echo "fetch_release: the web address failed; asking the API for asset $id" >&2 + curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors --connect-timeout 20 --max-time 600 \ + "${auth[@]}" -H "Accept: application/octet-stream" \ + -o "$dest" "https://api.github.com/repos/$owner/$repo/releases/assets/$id" +} + for i in $(seq 1 "$attempts"); do rm -f "$dest" # --retry-all-errors is what covers exit 52; the rest bound how long a single @@ -53,7 +80,7 @@ for i in $(seq 1 "$attempts"); do if curl -fsSL \ --retry 3 --retry-delay 2 --retry-all-errors \ --connect-timeout 20 --max-time 600 \ - -o "$dest" "$url"; then + -o "$dest" "$url" || { rm -f "$dest"; api_fetch; }; then if verify; then [ "$i" -eq 1 ] || echo "fetch_release: succeeded on attempt $i" >&2 exit 0 From c82c95cc308c8adebad3b581e9ff3286820fb43c Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 14:59:06 +0900 Subject: [PATCH 34/45] pack: several programs, none named after the package: the first by name, as before, and said (B3 without a refusal) The refusal broke an existing `mcpp pack` (mcppls). The program packed is said with the others and the two ways to choose (`mcpp pack `, `--all-programs`). e2e 1043. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../docs/2026-11-09-target-model-design.md | 3 ++- CHANGELOG.md | 5 +++-- src/pack/pipeline.cppm | 21 ++++++++----------- ..._and_a_dotted_name_are_packed_as_stated.sh | 21 +++++++++++-------- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/.agents/docs/2026-11-09-target-model-design.md b/.agents/docs/2026-11-09-target-model-design.md index 21f8f035d..5f2304581 100644 --- a/.agents/docs/2026-11-09-target-model-design.md +++ b/.agents/docs/2026-11-09-target-model-design.md @@ -124,7 +124,7 @@ Emscripten SDK。openkal-platform 的 hosted 部分为提供者,服务 `*-linu | G11 执行宿主 | 运行器协议:传输、运行、输出流、退出码,由 runner 包实现(VM、ssh、设备);无运行器的异系统产物为「已构建,未运行」 | | B1 | 生成的清单以 TOML 引号书写含 `.` 的目标名 | | B2 | 平台规定的产物后缀不被宿主后缀规则覆盖;冲突在规划期报告 | -| B3 | 多个程序可打包时拒绝并说明,提供打包一个包的全部程序的写法 | +| B3 | 多个程序可打包时照旧打包(以包命名者,否则按名字的第一个)并说明其余程序与两种选择写法(`mcpp pack `、`--all-programs`) | | 构建插件(SPEC-007) | SPEC-013 §1.2 的分工不变:行、提供者、层只能声明(解析工具链与依赖图时尚无程序可运行);`build`、`link`、`runner` 可由插件陈述,来源记录;构建程序的 `artifact-name` 不改变平台的产物格式 | ## 9. 多视角自审 @@ -410,3 +410,4 @@ mcpp 的单元测试(`tests/unit/` 166 个文件,以及各成员的 `modules | 交叉目标的 `[target.] toolchain` | 构建程序取被它替换的规格 | 与行的 pin 同一规则;该段名的是目标的编译器 | | mcppls 的自启动测试 | mtest `child` 对象;需独立缓存的两个测试放在 `tests/isolated/` | mcpplibs.os 不修改进程自身环境,隔离缓存只能由新进程获得 | | openkal-run 的实现 | 能力槽加按系统默认(D2,openkal-image 0.5.4) | 图或平台已有的实现优先,FreeBSD 等新系统无需修补该包 | +| B3 无同名程序 | 照旧打包按名字的第一个并说明,不拒绝 | 拒绝曾打破既有工程的 `mcpp pack`(mcppls);问题在于不说明,不在于选择本身 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 1073333f8..bd010ce77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,8 +89,9 @@ project's spelling and is kept, and a disagreement with the graph's C library is reported. - A git dependency names at most one of rev, tag and branch. -- `mcpp pack` with several programs packs the one named after the package and - names the others, and refuses when none is so named. +- `mcpp pack` with several programs packs the one named after the package, + else the first by name as before, and names the others with + `mcpp pack ` and `--all-programs`. - mcpp's own unit tests are mtest cases (mtest 0.5.1, mcpplibs.os 0.4.1); a skeleton's tests use mtest 0.5.1. diff --git a/src/pack/pipeline.cppm b/src/pack/pipeline.cppm index 02e3468eb..e592e01a6 100644 --- a/src/pack/pipeline.cppm +++ b/src/pack/pipeline.cppm @@ -537,19 +537,14 @@ std::optional member_plan(PackRun& run, GroupJob& g, MemberJob& m) { break; } } - // SEVERAL PROGRAMS ARE NOT PACKED BY A GUESS (mcpp#911 B3): the program - // named after the package is the package's, and the others are said; with - // none so named, the command names one or ships them all. + // SEVERAL PROGRAMS ARE NOT PACKED WITHOUT A WORD (mcpp#911 B3): the + // program named after the package is the package's; with none so named, + // the first by name, as before. Either way the others are said, with the + // two ways to choose otherwise. std::vector programs; for (auto const& lu : ctx.plan.linkUnits) if (is_program_link_unit(lu)) programs.push_back(&lu); - std::string names; - for (auto const* lu : programs) names += (names.empty() ? "" : ", ") + lu->targetName; - if (mainBinary.empty() && programs.size() > 1 && !opts.allPrograms) - return Refusal{2, about(run, m, std::format( - "the package has several programs ({}) and none is named after it\n" - " name one: mcpp pack \n or ship them together: mcpp pack --all-programs", - names))}; + const bool named = !mainBinary.empty(); if (mainBinary.empty() && !programs.empty()) { mainBinary = ctx.outputDir / programs.front()->output; chosenLu = programs.front(); @@ -561,8 +556,10 @@ std::optional member_plan(PackRun& run, GroupJob& g, MemberJob& m) { for (auto const* lu : programs) if (lu != chosenLu) others += (others.empty() ? "" : ", ") + lu->targetName; mcpp::ui::note(std::format( - "packing {}, the program named after the package; its other programs are not packed " - "({}): `mcpp pack --all-programs` ships them beside it", chosenLu->targetName, others)); + "packing {}, {}; its other programs are not packed ({}): `mcpp pack ` names " + "another, `mcpp pack --all-programs` ships them beside it", chosenLu->targetName, + named ? "the program named after the package" : "the first by name, as none is named after the package", + others)); } // Passed to `make_plan` rather than re-derived from the file: `make_plan` // has only `mainBinary` and would otherwise have to ask the triple and diff --git a/tests/e2e/1043_several_programs_and_a_dotted_name_are_packed_as_stated.sh b/tests/e2e/1043_several_programs_and_a_dotted_name_are_packed_as_stated.sh index a274efba0..5f9eff268 100755 --- a/tests/e2e/1043_several_programs_and_a_dotted_name_are_packed_as_stated.sh +++ b/tests/e2e/1043_several_programs_and_a_dotted_name_are_packed_as_stated.sh @@ -4,12 +4,13 @@ source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" # 1043 -- several programs are packed as stated, and a program whose name has a dot is packed # (mcpp#911 B1, B3). # -# A package with several programs and none named after it is not packed by a guess: `mcpp pack` -# refuses and names the two forms, `mcpp pack ` and `mcpp pack --all-programs`. With -# one named after the package, that one is packed and the others are said. A manifest `mcpp pack` +# A package with several programs is not packed without a word: the one named after the package, +# else the first by name, is packed, and the others are said with the two ways to choose +# otherwise, `mcpp pack ` and `mcpp pack --all-programs`. A manifest `mcpp pack` # generates quotes a target name with a dot (`COMMAND.COM`), so the generated project parses. # -# Criteria: the refusal lists the programs; `--all-programs` ships both under bin/; a single-file +# Criteria: with none named after the package the first by name is packed and the others are said; +# `--all-programs` ships both under bin/; a single-file # pack of `COMMAND.COM` builds its launcher and the file runs with the program's exit status. set -e t=$(mktemp -d); trap 'rm -rf "$t"' EXIT @@ -32,11 +33,13 @@ echo 'int main() { return 0; }' > "$t/src/alpha.cpp" echo 'int main() { return 3; }' > "$t/src/command.cpp" cd "$t" -set +e; out=$("$MCPP" pack 2>&1); rc=$?; set -e -[ "$rc" != 0 ] || fail "several programs were packed by a guess" "$out" -grep -q "several programs (COMMAND.COM, alpha) and none is named after it" <<<"$out" || fail "the refusal does not list the programs" "$out" -grep -q "mcpp pack --all-programs" <<<"$out" || fail "the refusal does not name --all-programs" "$out" -echo " ok several programs, none named after the package: refused with both forms" +out=$("$MCPP" pack 2>&1) || fail "several programs, none named after the package, were not packed" "$out" +grep -q "packing COMMAND.COM, the first by name, as none is named after the package" <<<"$out" \ + || fail "the program packed is not said" "$out" +grep -q "(alpha)" <<<"$out" && grep -q "mcpp pack --all-programs" <<<"$out" \ + || fail "the others and the ways to choose are not said" "$out" +rm -rf target/dist +echo " ok several programs, none named after the package: the first by name, the others said" out=$("$MCPP" pack --all-programs 2>&1) || fail "--all-programs did not pack" "$out" listing=$(tar tzf target/dist/multi-0.1.0-*.tar.gz) From 6e73227b20655d22db2f31229c0829524ba906b9 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 15:10:57 +0900 Subject: [PATCH 35/45] ci: the e2e mtest engine is the xlings package xim:mtest, installed with an index refresh per attempt Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/actions/e2e-mtest/action.yml | 48 +++++++++------------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/.github/actions/e2e-mtest/action.yml b/.github/actions/e2e-mtest/action.yml index 03836d021..dd1d2251b 100644 --- a/.github/actions/e2e-mtest/action.yml +++ b/.github/actions/e2e-mtest/action.yml @@ -1,7 +1,7 @@ name: e2e-mtest description: > mtest for the e2e suite (design 2026-11-08 W8; tests/e2e/mtest.toml): the - release this action pins, its sha256 checked, and the stores main's last + release this action pins, installed by xlings, and the stores main's last successful CI run kept for this host, taken into tests/e2e/.mtest so that `--shard` divides the suite by measured time and a run orders its tests longest first. Exports E2E_MTEST for run_all.sh. A run with no store of main @@ -12,13 +12,9 @@ inputs: description: the host's name in the stores' artifact names (linux, macos, windows) required: true version: - description: the mtest release + description: the mtest release, an xlings package (xim:mtest), whose sha256 the index states required: false default: '0.5.2' - sha256: - description: sha256 of mtest--openkal-bundle-all.tar.gz - required: false - default: 'be443f8c546b3f4628dcd3f1eb6f45b4d698a7f805232f966349faf8250863c6' token: description: a token that reads this repository's actions required: false @@ -27,39 +23,25 @@ inputs: runs: using: composite steps: - # The bundle is a release's fixed bytes: kept once per version, so that a - # job downloads it only when no job before it did. - - name: The mtest bundle, kept - id: bundle - uses: actions/cache@v4 - with: - path: ${{ runner.temp }}/mtest-bundle - key: mtest-bundle-${{ inputs.version }}-${{ inputs.sha256 }} + # THE ENGINE IS AN XLINGS PACKAGE (xim:mtest): installed as the job installs + # mcpp and its toolchains, verified by the index's sha256, from the mirror + # xlings is configured for, and kept in the job's xlings cache. - name: mtest shell: bash env: MTEST_VERSION: ${{ inputs.version }} - MTEST_SHA256: ${{ inputs.sha256 }} - GITHUB_TOKEN: ${{ inputs.token }} run: | set -euo pipefail - archive="mtest-$MTEST_VERSION-openkal-bundle-all.tar.gz" - temp="$RUNNER_TEMP"; python=python3 - if [ "$RUNNER_OS" = Windows ]; then temp=$(cygpath -u "$RUNNER_TEMP"); python=python; fi - mkdir -p "$temp/mtest-bundle" - if [ ! -s "$temp/mtest-bundle/$archive" ]; then - # Retried, and by the API's address when the web's fails (fetch_release.sh). - bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ - "https://github.com/mcpp-community/mtest/releases/download/v$MTEST_VERSION/$archive" \ - "$temp/mtest-bundle/$archive" - fi - cp "$temp/mtest-bundle/$archive" "$temp/$archive" - actual=$("$python" -c "import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$temp/$archive") - [ "$actual" = "$MTEST_SHA256" ] || { echo "::error::$archive: sha256 $actual, the action pins $MTEST_SHA256"; exit 1; } - mkdir -p "$temp/mtest" && tar xzf "$temp/$archive" -C "$temp/mtest" - launcher="$temp/mtest/mtest-$MTEST_VERSION-openkal-bundle-all/bin/mtest" - [ "$RUNNER_OS" = Windows ] && launcher="$launcher.exe" - "$launcher" --version + # An attempt refreshes the index first: a job's cached index can predate the release. + for attempt in 1 2 3 4 5; do + xlings update > /dev/null 2>&1 || true + if xlings install "mtest@$MTEST_VERSION" -y; then break; fi + [ "$attempt" = 5 ] && { echo "::error::mtest@$MTEST_VERSION could not be installed"; exit 1; } + sleep 30 + done + launcher=$(command -v mtest) + "$launcher" --version | grep -qF "$MTEST_VERSION" \ + || { echo "::error::xlings's mtest is not $MTEST_VERSION: $("$launcher" --version)"; exit 1; } echo "E2E_MTEST=$launcher" >> "$GITHUB_ENV" # The pull request's change, for the `pr` set: the base's tip and HEAD's # parents, so that `git diff ...HEAD` has its merge base. When they From 77de8a4c15273a278b252bab09643f5208615052 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 15:25:56 +0900 Subject: [PATCH 36/45] ci: the pinned mcpp is installed with an index refresh per failed attempt, by git at the last A job's cached xlings index predates a newly published pin, and xlings keeps it when its anonymous refresh fails (GitHub's 2026-11-09 incident). Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/tools/install_pinned_mcpp.sh | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/tools/install_pinned_mcpp.sh b/.github/tools/install_pinned_mcpp.sh index df8c2451c..18fbf2845 100755 --- a/.github/tools/install_pinned_mcpp.sh +++ b/.github/tools/install_pinned_mcpp.sh @@ -64,7 +64,21 @@ XL="$XL_HOME/.xlings/subos/default/bin/xlings${EXE}" echo "FAIL: no xlings at $XL_HOME/.xlings/subos/default/bin nor on PATH" >&2 exit 1; } -"$XL" install -y -u -g >&2 +# THE INDEX MAY BE OLDER THAN THE PIN. A job's xlings comes from the cache with +# the index of when the cache was saved, and xlings keeps that index when its +# refresh fails: an anonymous download of the index from GitHub answered 504 +# during GitHub's incident of 2026-11-09 ("timeouts due to surge in anonymous +# users"), and the pin, published after the cache, was "not found". Each failed +# attempt refreshes the index and waits; the last two take it by git, which +# xlings otherwise declines for an index its release artifact manages. +for attempt in 1 2 3 4 5; do + if "$XL" install -y -u -g >&2; then break; fi + [ "$attempt" = 5 ] && { echo "FAIL: the pinned mcpp could not be installed" >&2; exit 1; } + sleep $(( attempt * 15 )) + if [ "$attempt" -ge 3 ]; then XLINGS_INDEX_SOURCE=git "$XL" update >&2 || true + else "$XL" update >&2 || true + fi +done MCPP="$XL_HOME/.xlings/subos/default/bin/mcpp${EXE}" [ -x "$MCPP" ] || { echo "FAIL: no mcpp at $MCPP after install" >&2; exit 1; } From a09b8910b395900001ea8a197ebca531e7da3e63 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 15:28:54 +0900 Subject: [PATCH 37/45] ci: xlings by GLOBAL, then by CN, then the release itself, for the bootstrap mcpp and the e2e mtest A job's cached xlings index can predate a published pin, and xlings keeps it when its anonymous refresh fails (GitHub's 2026-11-09 incident, "timeouts due to surge in anonymous users"). install_pinned_mcpp.sh tries the configured mirror, refreshed; then CN (GitCode first), refreshed, the last by git; then the release (install.sh, GitHub then GitCode, sha256 checked; on Windows the zip through fetch_release.sh). e2e-mtest does the same for xim:mtest, with the release bundle checked against its pinned sha256. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/actions/e2e-mtest/action.yml | 40 +++++++++++++++----- .github/tools/install_pinned_mcpp.sh | 55 +++++++++++++++++++--------- 2 files changed, 68 insertions(+), 27 deletions(-) diff --git a/.github/actions/e2e-mtest/action.yml b/.github/actions/e2e-mtest/action.yml index dd1d2251b..a4427d4ee 100644 --- a/.github/actions/e2e-mtest/action.yml +++ b/.github/actions/e2e-mtest/action.yml @@ -15,6 +15,10 @@ inputs: description: the mtest release, an xlings package (xim:mtest), whose sha256 the index states required: false default: '0.5.2' + sha256: + description: sha256 of mtest--openkal-bundle-all.tar.gz, for the release taken when xlings cannot install it + required: false + default: 'be443f8c546b3f4628dcd3f1eb6f45b4d698a7f805232f966349faf8250863c6' token: description: a token that reads this repository's actions required: false @@ -23,25 +27,41 @@ inputs: runs: using: composite steps: - # THE ENGINE IS AN XLINGS PACKAGE (xim:mtest): installed as the job installs - # mcpp and its toolchains, verified by the index's sha256, from the mirror - # xlings is configured for, and kept in the job's xlings cache. + # THE ENGINE IS AN XLINGS PACKAGE (xim:mtest), installed as the job installs + # mcpp, verified by the index's sha256, and kept in the job's xlings cache: + # by the configured mirror (GLOBAL), then by CN (GitCode first), each with a + # refreshed index; else the release bundle itself (fetch_release.sh: the + # web address, then the API's), checked against the pinned sha256. - name: mtest shell: bash env: MTEST_VERSION: ${{ inputs.version }} + MTEST_SHA256: ${{ inputs.sha256 }} + GITHUB_TOKEN: ${{ inputs.token }} run: | set -euo pipefail - # An attempt refreshes the index first: a job's cached index can predate the release. - for attempt in 1 2 3 4 5; do + launcher="" + for mirror in GLOBAL CN; do + xlings config --mirror "$mirror" > /dev/null 2>&1 || true xlings update > /dev/null 2>&1 || true - if xlings install "mtest@$MTEST_VERSION" -y; then break; fi - [ "$attempt" = 5 ] && { echo "::error::mtest@$MTEST_VERSION could not be installed"; exit 1; } - sleep 30 + if xlings install "mtest@$MTEST_VERSION" -y; then launcher=$(command -v mtest); break; fi done - launcher=$(command -v mtest) + xlings config --mirror GLOBAL > /dev/null 2>&1 || true + if [ -z "$launcher" ]; then + echo "::warning::xlings could not install mtest $MTEST_VERSION; taking the release itself" + archive="mtest-$MTEST_VERSION-openkal-bundle-all.tar.gz" + temp="$RUNNER_TEMP"; python=python3 + if [ "$RUNNER_OS" = Windows ]; then temp=$(cygpath -u "$RUNNER_TEMP"); python=python; fi + bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ + "https://github.com/mcpp-community/mtest/releases/download/v$MTEST_VERSION/$archive" "$temp/$archive" + actual=$("$python" -c "import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$temp/$archive") + [ "$actual" = "$MTEST_SHA256" ] || { echo "::error::$archive: sha256 $actual, the action pins $MTEST_SHA256"; exit 1; } + mkdir -p "$temp/mtest" && tar xzf "$temp/$archive" -C "$temp/mtest" + launcher="$temp/mtest/mtest-$MTEST_VERSION-openkal-bundle-all/bin/mtest" + [ "$RUNNER_OS" = Windows ] && launcher="$launcher.exe" + fi "$launcher" --version | grep -qF "$MTEST_VERSION" \ - || { echo "::error::xlings's mtest is not $MTEST_VERSION: $("$launcher" --version)"; exit 1; } + || { echo "::error::the mtest at $launcher is not $MTEST_VERSION: $("$launcher" --version)"; exit 1; } echo "E2E_MTEST=$launcher" >> "$GITHUB_ENV" # The pull request's change, for the `pr` set: the base's tip and HEAD's # parents, so that `git diff ...HEAD` has its merge base. When they diff --git a/.github/tools/install_pinned_mcpp.sh b/.github/tools/install_pinned_mcpp.sh index 18fbf2845..cf3dc3802 100755 --- a/.github/tools/install_pinned_mcpp.sh +++ b/.github/tools/install_pinned_mcpp.sh @@ -64,23 +64,46 @@ XL="$XL_HOME/.xlings/subos/default/bin/xlings${EXE}" echo "FAIL: no xlings at $XL_HOME/.xlings/subos/default/bin nor on PATH" >&2 exit 1; } -# THE INDEX MAY BE OLDER THAN THE PIN. A job's xlings comes from the cache with -# the index of when the cache was saved, and xlings keeps that index when its -# refresh fails: an anonymous download of the index from GitHub answered 504 -# during GitHub's incident of 2026-11-09 ("timeouts due to surge in anonymous -# users"), and the pin, published after the cache, was "not found". Each failed -# attempt refreshes the index and waits; the last two take it by git, which -# xlings otherwise declines for an index its release artifact manages. -for attempt in 1 2 3 4 5; do - if "$XL" install -y -u -g >&2; then break; fi - [ "$attempt" = 5 ] && { echo "FAIL: the pinned mcpp could not be installed" >&2; exit 1; } - sleep $(( attempt * 15 )) - if [ "$attempt" -ge 3 ]; then XLINGS_INDEX_SOURCE=git "$XL" update >&2 || true - else "$XL" update >&2 || true - fi -done +PIN=$(grep -oE '"mcpp"[[:space:]]*:[[:space:]]*"[^"]+"' "$REPO_DIR/.xlings.json" \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1 || true) +# XLINGS FIRST, ITS MIRRORS IN TURN, THE RELEASE LAST. A job's xlings comes from +# the cache with the index of when the cache was saved, and xlings keeps that +# index when its refresh fails: during GitHub's incident of 2026-11-09 +# ("timeouts due to surge in anonymous users") the anonymous download of the +# index answered 504 and a pin published after the cache was "not found". +# 1-2 the configured mirror (GLOBAL), the index refreshed before the second; +# 3-4 the CN mirror (GitCode first, GitHub after), refreshed; the fourth by +# git, which xlings otherwise declines for an artifact-managed index; +# then the release itself (install.sh: GitHub, then GitCode, sha256 checked). MCPP="$XL_HOME/.xlings/subos/default/bin/mcpp${EXE}" +installed=no +for attempt in 1 2 3 4; do + case "$attempt" in + 2) "$XL" update >&2 || true ;; + 3) "$XL" config --mirror CN >&2 || true; "$XL" update >&2 || true ;; + 4) XLINGS_INDEX_SOURCE=git "$XL" update >&2 || true ;; + esac + if "$XL" install -y -u -g >&2; then installed=yes; break; fi + sleep $(( attempt * 10 )) +done +"$XL" config --mirror GLOBAL >&2 || true +if [ "$installed" = no ]; then + echo "::warning::xlings could not install mcpp $PIN; taking the release itself" >&2 + prefix="${RUNNER_TEMP:-$(mktemp -d)}/mcpp-release-$PIN" + if [ -n "$EXE" ]; then + zip="mcpp-$PIN-windows-x86_64.zip" + mkdir -p "$prefix" + bash "$REPO_DIR/.github/tools/fetch_release.sh" \ + "https://github.com/mcpp-community/mcpp/releases/download/v$PIN/$zip" "$prefix/$zip" >&2 + (cd "$prefix" && unzip -qo "$zip" && rm -f "$zip") + MCPP=$(find "$prefix" -type f -name "mcpp.exe" -path '*/bin/*' | head -1) + else + MCPP_VERSION="$PIN" MCPP_PREFIX="$prefix" MCPP_NO_PATH=1 bash "$REPO_DIR/install.sh" >&2 + MCPP="$prefix/bin/mcpp" + fi + [ -z "${GITHUB_PATH:-}" ] || dirname "$MCPP" >> "$GITHUB_PATH" +fi [ -x "$MCPP" ] || { echo "FAIL: no mcpp at $MCPP after install" >&2; exit 1; } # Assert the outcome rather than trust it. This is the ONLY place the pin is @@ -98,8 +121,6 @@ MCPP="$XL_HOME/.xlings/subos/default/bin/mcpp${EXE}" # — it just silently truncates it to "2026.7.27" on both sides, so the # comparison below would accept any release of that day and this guard would # quietly stop guarding. -PIN=$(grep -oE '"mcpp"[[:space:]]*:[[:space:]]*"[^"]+"' "$REPO_DIR/.xlings.json" \ - | grep -oE '[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1 || true) GOT=$("$MCPP" --version 2>/dev/null | head -1 \ | grep -oE '[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1 || true) # Exact comparison: a substring match would let a pin of 0.0.10 be satisfied by From bdacf39f4fe3f38db11124651dc437ff65a3cf53 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 16:09:07 +0900 Subject: [PATCH 38/45] test: e2e 298 overrides only a row its convention serves on this host A macOS host serves aarch64-linux-musl through openkal-platform, whose compiler is llvm; llvm alone there is the row's own service. The test now picks a row whose selection takes a non-llvm compiler and otherwise declares a skip, which the target-matrix step accepts off Linux. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/workflows/ci-target-matrix.yml | 10 +++--- ...ding_a_convention_requires_replacing_it.sh | 35 ++++++++++++++----- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci-target-matrix.yml b/.github/workflows/ci-target-matrix.yml index b6bdcb4bc..495ffddb3 100644 --- a/.github/workflows/ci-target-matrix.yml +++ b/.github/workflows/ci-target-matrix.yml @@ -123,9 +123,9 @@ jobs: # payload (it backs the host row), so that job uses `check` and the # test is guaranteed to be exercised somewhere on every run. Without # that, a reason accepted everywhere is a test that runs nowhere. - check_or_declared_skip() { # log ok-line acceptable-skip-substring + check_or_declared_skip() { # log ok-line acceptable-skip-pattern (ERE) grep -qF "$2" "$1".log && { echo " ok $1"; return 0; } - if grep -q '^SKIP:' "$1".log && grep -qF "$3" "$1".log; then + if grep -q '^SKIP:' "$1".log && grep -qE "$3" "$1".log; then echo " ok $1 (declared skip: $(grep -m1 '^SKIP:' "$1".log))" return 0 fi @@ -153,11 +153,13 @@ jobs: check 298_overriding_a_convention_requires_replacing_it.sh \ "OK: a convention may be overridden, but not merely removed" || fail=1 else - # Non-Linux rows may restore a cache with no declared LLVM. + # Non-Linux rows may restore a cache with no declared LLVM, and a + # macOS host serves every gcc-pinned row through openkal-platform, + # whose compiler is llvm: no row there is overridden by llvm. # Both Linux hosts require the assertion after native provisioning. check_or_declared_skip 298_overriding_a_convention_requires_replacing_it.sh \ "OK: a convention may be overridden, but not merely removed" \ - "llvm is not installed here" || fail=1 + "llvm is not installed here|served by a non-llvm convention" || fail=1 fi # ── 2026.8.26.2: an answer mcpp already had, now used ───────────── diff --git a/tests/e2e/298_overriding_a_convention_requires_replacing_it.sh b/tests/e2e/298_overriding_a_convention_requires_replacing_it.sh index 63ae7c6aa..412e2bf9d 100644 --- a/tests/e2e/298_overriding_a_convention_requires_replacing_it.sh +++ b/tests/e2e/298_overriding_a_convention_requires_replacing_it.sh @@ -85,19 +85,38 @@ hostArch="$("$MCPP" toolchain list --format json 2>/dev/null \ # A ROW WHOSE PAYLOAD llvm PAIRS WITH IS NOT ONE. The MinGW-w64 payload is # clang's sysroot as well as gcc's (SPEC-016 §3, e2e 1044): `x86_64-windows-gnu` # with llvm alone is served, not overridden, and is left out here. +# +# AND A ROW IS OVERRIDDEN ONLY WHERE ITS CONVENTION IS WHAT SERVES IT HERE. Since +# 2026.11.9.1 a row is served by a provider chosen per host (SPEC-016 §4): +# `aarch64-linux-musl` takes the musl cross payload on a Linux host and +# openkal-platform, whose compiler is llvm, on a macOS host. There llvm alone is +# the row's own service, and nothing was removed. The compiler the selection +# takes with no compiler stated is that service, so a candidate counts only +# where it is not llvm. +mkdir -p "$work/neutral" +printf '[package]\nname = "neutral"\nversion = "0.1.0"\n' > "$work/neutral/mcpp.toml" +served_by_its_convention() { + local fam + fam="$(cd "$work/neutral" && "$MCPP" why toolchain --target "$1" --format json 2>/dev/null \ + | jq -r '.data.compiler.family // ""' | tr -d '\r')" + [ -n "$fam" ] && [ "$fam" != llvm ] +} pick() { - "$MCPP" toolchain list --format json 2>/dev/null \ - | jq -r --arg a "$1" '[.data.targets[] - | select(.pin | startswith("gcc")) - | select(.target != "x86_64-windows-gnu") - | select(.status != "planned") - | select($a == "" or (.target | startswith($a + "-"))) - | .target][0] // empty' | tr -d '\r' + local t + for t in $("$MCPP" toolchain list --format json 2>/dev/null \ + | jq -r --arg a "$1" '[.data.targets[] + | select(.pin | startswith("gcc")) + | select(.target != "x86_64-windows-gnu") + | select(.status != "planned") + | select($a == "" or (.target | startswith($a + "-"))) + | .target] | unique[]' | tr -d '\r'); do + served_by_its_convention "$t" && { echo "$t"; return; } + done } pinned="$(pick "$hostArch")" [ -n "$pinned" ] || pinned="$(pick "")" if [ -z "$pinned" ]; then - echo "SKIP: no target row here pins a non-llvm toolchain" + echo "SKIP: no target row here is served by a non-llvm convention" exit 0 fi From 0a073b8619c9b33766c3d5d519e6e9377f9cbe80 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 16:16:44 +0900 Subject: [PATCH 39/45] ci: a script the pr set leaves out is reported unaffected, and R5 accepts it for that run With main's coverage map in place the pull request's Linux shards ran only the scripts the change affects; the coverage check read every other script as uncovered and the host requirement of 1033 as unmet. run_all.sh now reports the left-out scripts as unaffected, the check counts them for that run and host, and a shard the set gives no script is not mtest's failure. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/tools/check_e2e_coverage.py | 19 ++++++++-- CHANGELOG.md | 7 ++++ docs/90-build-from-source.md | 4 +- docs/zh/90-build-from-source.md | 3 +- tests/e2e/run_all.sh | 48 +++++++++++++++++++----- tests/scripts/test_check_e2e_coverage.py | 23 ++++++++++-- 6 files changed, 85 insertions(+), 19 deletions(-) diff --git a/.github/tools/check_e2e_coverage.py b/.github/tools/check_e2e_coverage.py index fae518366..077d34521 100644 --- a/.github/tools/check_e2e_coverage.py +++ b/.github/tools/check_e2e_coverage.py @@ -18,6 +18,10 @@ under tests/e2e: ran some report says pass, fail or timeout; + unaffected no report ran it, and a run narrowed to a set (`E2E_SET`, the + `pr` set of a pull request) says the change does not affect it: + the full runs of main are its coverage, and this run's claim is + about the tests the change reaches; job no report ran it, but a workflow names it outside a comment, as a whole token: by file name, by name without `.sh`, or by its number as an `E2E_ONLY` pattern such as `239_*.sh`. A dedicated job runs it @@ -34,7 +38,8 @@ calls a target row `verified` when CI builds and runs its artefact, and the `*-openkal` rows claim that for every system. tests/e2e/host-requirements.tsv lists such a test with the hosts its result must come from; a listed test that -did not pass on one of them, or that does not exist, fails the check. +did not pass on one of them, or that does not exist, fails the check; one a +set left out on that host as unaffected meets it for that run. With --timings-out DIR it writes `.tsv`, the measured duration of every test that ran, merged across that host's shards, in the format run_all.sh reads @@ -133,6 +138,7 @@ def main() -> int: ran: dict[str, set[str]] = defaultdict(set) passed: dict[str, set[str]] = defaultdict(set) skipped: dict[str, set[str]] = defaultdict(set) + unaffected: dict[str, set[str]] = defaultdict(set) shard_ms: dict[tuple[str, int], int] = defaultdict(int) timings: dict[str, dict[str, int]] = defaultdict(dict) reports = 0 @@ -146,6 +152,8 @@ def main() -> int: timings[host][test] = ms elif status == "skip": skipped[test].add(f"{host}: {detail}") + elif status == "unaffected": + unaffected[test].add(host) if reports == 0: print(f"no report under {args.reports}: nothing can be said about coverage") return 1 @@ -157,6 +165,8 @@ def main() -> int: counts["ran"] += 1 if test in exceptions: stale.append(f"{test} is excused but ran on {', '.join(sorted(ran[test]))}") + elif test in unaffected: + counts["unaffected"] += 1 elif named_by_a_workflow(test, workflows): counts["job"] += 1 elif test in exceptions: @@ -171,13 +181,14 @@ def main() -> int: if test not in tests: stale.append(f"{test} is required on hosts but does not exist") continue - for host in sorted(hosts - passed[test]): + for host in sorted(hosts - passed[test] - unaffected[test]): why = [d for d in skipped[test] if d.startswith(f"{host}: ")] unmet.append(f"{test} did not pass on {host}" f"{' (' + '; '.join(why) + ')' if why else (' (it ran and did not pass)' if host in ran[test] else ' (no report of it there)')}") - print(f"{len(tests)} tests: {counts['ran']} ran on a shard, {counts['job']} run by a " - f"dedicated job, {counts['excused']} excused, {len(uncovered)} uncovered") + print(f"{len(tests)} tests: {counts['ran']} ran on a shard, {counts['unaffected']} unaffected by " + f"the change, {counts['job']} run by a dedicated job, {counts['excused']} excused, " + f"{len(uncovered)} uncovered") for (host, shard), ms in sorted(shard_ms.items()): print(f" {host} shard {shard}: {ms / 60000:.1f} min of tests") for line in uncovered: diff --git a/CHANGELOG.md b/CHANGELOG.md index bd010ce77..d8b13887c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,13 @@ naming both; the two programs were a file and a directory of one path. - The repository's stray empty `.edited` is removed; e2e 859 fails naming the missing record rather than writing one in the working directory. +- CI: a pull request's e2e shards report the scripts the `pr` set leaves out as + `unaffected`, and the check that every script runs somewhere accepts them + for that run; with main's coverage map in place, it had read them as + uncovered. A shard the set gives no script is not a failure of mtest. +- e2e 298 picks a row whose compiler on this host is its convention's: a macOS + host serves `aarch64-linux-musl` through openkal-platform, whose compiler is + llvm, and there llvm alone overrides nothing. ## [2026.11.8.1] - 2026-11-08 diff --git a/docs/90-build-from-source.md b/docs/90-build-from-source.md index 028ecbb1b..bb3d5aacd 100644 --- a/docs/90-build-from-source.md +++ b/docs/90-build-from-source.md @@ -126,7 +126,9 @@ runs beside it; a script that changes the shared home's configuration says so). divides by the measured history of the store (`tests/e2e/.mtest`, which CI takes from main's last run), `E2E_JOBS` runs that many scripts at once, and `E2E_SET=pr` runs the scripts a change affects by the nightly coverage map -(`.github/workflows/e2e-coverage.yml`). `mtest explain` in `tests/e2e` says where +(`.github/workflows/e2e-coverage.yml`); a script the set leaves out is reported +`unaffected`, which the check that every script runs somewhere (rule R5) accepts +for that run, main's full runs being its coverage. `mtest explain` in `tests/e2e` says where a script failed and how to run it again. ## Writing a check that measures something diff --git a/docs/zh/90-build-from-source.md b/docs/zh/90-build-from-source.md index e01ce28da..5d9731e3c 100644 --- a/docs/zh/90-build-from-source.md +++ b/docs/zh/90-build-from-source.md @@ -122,7 +122,8 @@ MCPP= bash tests/e2e/02_new_build_run.sh 行是它运行时占有的资源(`machine`:没有别的脚本与它并行;改动共享 home 配置的脚本这样声明)。 `run_all.sh` 在 `E2E_MTEST=` 下经由它运行整套:`E2E_SHARD` 按存储(`tests/e2e/.mtest`, CI 取自 main 的上一次运行)中测得的历史划分,`E2E_JOBS` 同时运行多少个脚本,`E2E_SET=pr` -按夜间的覆盖映射(`.github/workflows/e2e-coverage.yml`)运行变更影响的脚本。在 `tests/e2e` +按夜间的覆盖映射(`.github/workflows/e2e-coverage.yml`)运行变更影响的脚本;集合留下的脚本报告为 +`unaffected`,「每个脚本都在某处运行」的检查(规则 R5)对该次运行接受它,其覆盖由 main 的完整运行承担。在 `tests/e2e` 中执行 `mtest explain` 可得知脚本在哪里失败以及如何再次运行。 ## 写一条真的在测东西的判据 diff --git a/tests/e2e/run_all.sh b/tests/e2e/run_all.sh index a3da4debe..16316fc5f 100755 --- a/tests/e2e/run_all.sh +++ b/tests/e2e/run_all.sh @@ -499,25 +499,34 @@ fi # the smoke and regression sets; a test the map does not know is affected). # E2E_ONLY and E2E_EXCLUDE select as below. Each result is reported as the # serial run reports it. +# The selection common to a run and its listings, in MTEST_SELECTION: this +# machine's capabilities, E2E_ONLY and E2E_EXCLUDE. +MTEST_SELECTION=() +for cap in "${KNOWN_CAPS[@]}"; do + [[ " ${CAPS[*]} " == *" $cap "* ]] || MTEST_SELECTION+=(--exclude-tag "requires:$cap") +done +[[ -n "${E2E_ONLY:-}" ]] && MTEST_SELECTION+=(--program "$E2E_ONLY") +[[ -n "${E2E_EXCLUDE:-}" ]] && MTEST_SELECTION+=(--exclude-program "$E2E_EXCLUDE") run_by_mtest() { - local args=(run --jobs "${E2E_JOBS:-1}" --timeout "${E2E_TEST_TIMEOUT}s" --report-json "$1") - local cap - for cap in "${KNOWN_CAPS[@]}"; do - [[ " ${CAPS[*]} " == *" $cap "* ]] || args+=(--exclude-tag "requires:$cap") - done + local args=(run --jobs "${E2E_JOBS:-1}" --timeout "${E2E_TEST_TIMEOUT}s" --report-json "$1" + ${MTEST_SELECTION[@]+"${MTEST_SELECTION[@]}"}) (( SHARD_TOTAL > 1 )) && args+=(--shard "$SHARD_IDX/$SHARD_TOTAL") [[ -n "${E2E_SET:-}" ]] && args+=(--set "$E2E_SET") # E2E_COVERAGE: the run measures the coverage map ([coverage] of mtest.toml). [[ -n "${E2E_COVERAGE:-}" ]] && args+=(--coverage) - [[ -n "${E2E_ONLY:-}" ]] && args+=(--program "$E2E_ONLY") - [[ -n "${E2E_EXCLUDE:-}" ]] && args+=(--exclude-program "$E2E_EXCLUDE") (cd "$HERE" && MCPP="$MCPP" "$E2E_MTEST" "${args[@]}") } +# The programs of a listing, one per line; the arguments narrow it. +mtest_programs() { + (cd "$HERE" && MCPP="$MCPP" "$E2E_MTEST" list --json ${MTEST_SELECTION[@]+"${MTEST_SELECTION[@]}"} "$@") \ + | "$py" -c 'import json, sys +for u in json.load(sys.stdin).get("units", []): print(u["program"])' | tr -d '\r' +} if [[ -n "${E2E_MTEST:-}" ]]; then + py=python3; command -v python3 >/dev/null 2>&1 || py=python record="$(mktemp)" run_by_mtest "$record" || true # One ` ` line per case of the run's record. - py=python3; command -v python3 >/dev/null 2>&1 || py=python results="$("$py" - "$record" <<'PY' import json, sys try: @@ -541,9 +550,28 @@ PY *) ((FAIL++)); FAILED_TESTS+=("$name"); report fail "$name" "$ms" "failed" ;; esac done <<<"$results" + # A SET LEAVES OUT WHAT THE CHANGE DOES NOT AFFECT, AND THE REPORT SAYS SO. + # The coverage check (rule R5) reads a test no report ran as uncovered; one + # the set left out is `unaffected`: the full runs of main cover it. Every + # shard lists the same programs, the shard being the set's own division. + if [[ -n "${E2E_SET:-}" && -n "${E2E_REPORT:-}" ]]; then + inSet=" $(mtest_programs --set "$E2E_SET" | tr '\n' ' ') " + while read -r name; do + [[ -n "$name" && "$inSet" != *" $name "* ]] || continue + report unaffected "$name" 0 "not in the set '$E2E_SET'" + done < <(mtest_programs) + fi + # A run the set gave nothing ran nothing; any other empty run is mtest's + # failure. if [[ $((PASS + FAIL)) -eq 0 ]]; then - echo "FATAL: mtest reported no case (E2E_MTEST=$E2E_MTEST)" - exit 1 + part=(); (( SHARD_TOTAL > 1 )) && part=(--shard "$SHARD_IDX/$SHARD_TOTAL") + if [[ -n "${E2E_SET:-}" ]] \ + && [[ -z "$(mtest_programs --set "$E2E_SET" ${part[@]+"${part[@]}"})" ]]; then + echo "The set '$E2E_SET' gives this run no test." + else + echo "FATAL: mtest reported no case (E2E_MTEST=$E2E_MTEST)" + exit 1 + fi fi fi diff --git a/tests/scripts/test_check_e2e_coverage.py b/tests/scripts/test_check_e2e_coverage.py index ab570ae4e..54233693a 100644 --- a/tests/scripts/test_check_e2e_coverage.py +++ b/tests/scripts/test_check_e2e_coverage.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Fixture tests for .github/tools/check_e2e_coverage.py (rule R5). -A test that ran, a test a workflow names, and a test the exceptions excuse are -covered; a test that is none of these fails the check, and so does an exception +A test that ran, a test a set left out as unaffected, a test a workflow names, +and a test the exceptions excuse are covered; a test that is none of these fails the check, and so does an exception for a test that ran or that does not exist. """ @@ -46,7 +46,7 @@ def run_check(self, *extra: str) -> subprocess.CompletedProcess: def test_ran_named_and_excused_tests_are_covered(self) -> None: r = self.run_check() self.assertEqual(r.returncode, 0, r.stdout) - self.assertIn("1 ran on a shard, 2 run by a dedicated job, 1 excused, 0 uncovered", r.stdout) + self.assertIn("1 ran on a shard, 0 unaffected by the change, 2 run by a dedicated job, 1 excused, 0 uncovered", r.stdout) def test_a_name_in_a_comment_or_inside_a_longer_name_does_not_count(self) -> None: e2e = self.root / "tests" / "e2e" @@ -71,6 +71,23 @@ def test_a_test_that_runs_nowhere_fails(self) -> None: self.assertEqual(r.returncode, 1) self.assertIn("UNCOVERED: 50_nowhere.sh: linux: missing capability: llvm", r.stdout) + def test_a_test_a_set_left_out_is_unaffected_and_meets_its_host(self) -> None: + e2e = self.root / "tests" / "e2e" + (e2e / "50_untouched.sh").write_text("# x\n# requires: unix-shell\n") + (e2e / "host-requirements.tsv").write_text("50_untouched.sh\tlinux\tverified rows\n") + (self.reports / "e2e-report-linux-2.tsv").write_text( + "unaffected\t50_untouched.sh\t0\tnot in the set 'pr'\n") + r = self.run_check() + self.assertEqual(r.returncode, 0, r.stdout) + self.assertIn("1 unaffected by the change", r.stdout) + # Another host's unaffected report does not meet a requirement of linux. + (self.reports / "e2e-report-linux-2.tsv").unlink() + (self.reports / "e2e-report-macos-1.tsv").write_text( + "unaffected\t50_untouched.sh\t0\tnot in the set 'pr'\n") + r = self.run_check() + self.assertEqual(r.returncode, 1) + self.assertIn("REQUIRED ON HOST: 50_untouched.sh did not pass on linux", r.stdout) + def test_an_exception_for_a_test_that_ran_fails(self) -> None: (self.reports / "e2e-report-macos-1.tsv").write_text("pass\t30_excused.sh\t10\t\n") r = self.run_check() From 3a0e411413a40456de99533db404302b508c71ce Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 16:24:32 +0900 Subject: [PATCH 40/45] refactor: the key graph and the locked targets leave their phase functions step13_dependency_cache and step13_lockfile had passed the 400-line gate of #722 with this release's additions. The package graph the keys fold along (key_graph, with serves_root_tests) and the lock's target entries (lock_targets, target_drift) are functions of their own; behaviour is unchanged. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- src/build/prepare/plan.cpp | 55 ++++++++++++++++++++--------------- src/build/prepare/records.cpp | 49 +++++++++++++++++++------------ 2 files changed, 61 insertions(+), 43 deletions(-) diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index bf93a4f4a..e34c1351b 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -1588,6 +1588,36 @@ static std::expected step13_assembly_units(PrepareState& stat } +// The root's own `[dev-dependencies]` serve its tests, not its library, which a +// framework that uses the package under test reaches (names_the_root, +// graph_edges.cpp): the root's key does not fold them in. +static bool serves_root_tests(const DependencyEdge& e) { + return e.consumerPackageIndex == 0 && e.devTable; +} + +// The package graph the keys fold along: the declared edges, and the imports no +// declared edge covers. An import a declared edge already covers adds nothing. +// One that a declared path runs the other way is a module this build generates +// for the importer below its consumer's root, filed under the consumer by its +// path (e2e 849): not an upstream, and left out of `importedFrom` rather than +// closing a cycle. +static mcpp::graph::AdjacencyList key_graph(const PrepareState& state, + std::vector>& importedFrom) { + mcpp::graph::AdjacencyList pkgDeps(state.packages.size()); + for (auto& e : state.dependencyEdges) + if (e.consumerPackageIndex < pkgDeps.size() && !serves_root_tests(e)) + pkgDeps[e.consumerPackageIndex].push_back(e.dependencyPackageIndex); + for (std::size_t p = 0; p < importedFrom.size(); ++p) + std::erase_if(importedFrom[p], [&](std::size_t q) { + if (std::ranges::find(pkgDeps[p], q) != pkgDeps[p].end()) return true; + const auto above = mcpp::graph::closure(pkgDeps, std::array{q}, /*include_roots=*/false); + return std::ranges::find(above, p) != above.end(); + }); + for (std::size_t p = 0; p < importedFrom.size(); ++p) + pkgDeps[p].insert(pkgDeps[p].end(), importedFrom[p].begin(), importedFrom[p].end()); + return pkgDeps; +} + static std::expected step13_dependency_cache(PrepareState& state, BuildContext& ctx) { // ─── Global dependency cache: per-package keys, hit → stage edges ── // @@ -1728,30 +1758,7 @@ static std::expected step13_dependency_cache(PrepareState& st // below walks `mcpp::graph::topological_order` — dependencies before // dependents — instead of recursing, with an explicit cycle check up // front standing in for the old in-progress guard. - // The root's own `[dev-dependencies]` serve its tests, not its - // library, which a framework that uses the package under test reaches - // (names_the_root, graph_edges.cpp): the root's key does not fold them in. - auto serves_root_tests = [](const auto& e) { - return e.consumerPackageIndex == 0 && e.devTable; - }; - mcpp::graph::AdjacencyList pkgDeps(state.packages.size()); - for (auto& e : state.dependencyEdges) - if (e.consumerPackageIndex < pkgDeps.size() && !serves_root_tests(e)) - pkgDeps[e.consumerPackageIndex].push_back(e.dependencyPackageIndex); - // An import a declared edge already covers adds nothing. One that a - // declared path runs the other way is a module this build generates - // for the importer below its consumer's root, filed under the - // consumer by its path (e2e 849): not an upstream, and left out - // rather than closing a cycle. - for (std::size_t p = 0; p < importedFrom.size(); ++p) - std::erase_if(importedFrom[p], [&](std::size_t q) { - if (std::ranges::find(pkgDeps[p], q) != pkgDeps[p].end()) return true; - const auto above = mcpp::graph::closure(pkgDeps, std::array{q}, /*include_roots=*/false); - return std::ranges::find(above, p) != above.end(); - }); - for (std::size_t p = 0; p < importedFrom.size(); ++p) - pkgDeps[p].insert(pkgDeps[p].end(), importedFrom[p].begin(), importedFrom[p].end()); - auto keyOrder = mcpp::graph::topological_order(pkgDeps); + auto keyOrder = mcpp::graph::topological_order(key_graph(state, importedFrom)); if (!keyOrder) { keyCycleError = std::format( "dependency cycle through package '{}' while computing " diff --git a/src/build/prepare/records.cpp b/src/build/prepare/records.cpp index 2780532da..413893ce1 100644 --- a/src/build/prepare/records.cpp +++ b/src/build/prepare/records.cpp @@ -101,6 +101,34 @@ static std::string lock_identity(const mcpp::lockfile::LockedPackage& p) { return p.namespace_ + "\x1f" + lock_short_name(p); } +// THE IDENTITY THIS CONFIGURATION'S REQUEST WAS GIVEN (SPEC-016 §4.5), into +// `lock`, and the other requests' as the lock had them. Returns what the lock +// had for this request. +static std::optional lock_targets(const PrepareState& state, + mcpp::lockfile::Lockfile& lock) { + std::optional priorTarget; + auto const& c = state.targetChoice; + if (auto prior = mcpp::lockfile::load(state.workRoot / "mcpp.lock")) + for (auto const& t : prior->targets) { + if (!c.request.empty() && t.request == c.request) priorTarget = t; + else lock.targets.push_back(t); + } + if (!c.request.empty()) lock.targets.push_back({ c.request, c.identity, c.provider }); + std::ranges::sort(lock.targets, {}, &mcpp::lockfile::LockedTarget::request); + return priorTarget; +} + +// The drift line of a request the lock gave another identity, if it did. +static std::optional target_drift(const PrepareState& state, + const std::optional& prior) { + auto const& c = state.targetChoice; + if (!prior || prior->identity == c.identity) return std::nullopt; + return std::format("target {}: {} ({}) -> {} ({}){}", c.request, prior->identity, + prior->provider.empty() ? "the platform's" : prior->provider, + c.identity, c.provider.empty() ? "the platform's" : c.provider, + c.why.empty() ? std::string{} : "; " + c.why); +} + std::expected step13_lockfile(PrepareState& state, BuildContext& ctx) { // Write/update mcpp.lock for any version-based deps that succeeded. // Path deps are intentionally NOT locked — their source is local filesystem. @@ -413,19 +441,7 @@ std::expected step13_lockfile(PrepareState& state, BuildConte } // One order whichever configuration wrote the file last: by identity. std::ranges::stable_sort(lock.packages, {}, [](auto const& p) { return lock_identity(p); }); - // THE IDENTITY THIS CONFIGURATION'S REQUEST WAS GIVEN (SPEC-016 §4.5), - // and the other requests' as the lock had them. - std::optional priorTarget; - { - auto const& c = state.targetChoice; - if (auto prior = mcpp::lockfile::load(state.workRoot / "mcpp.lock")) - for (auto const& t : prior->targets) { - if (!c.request.empty() && t.request == c.request) priorTarget = t; - else lock.targets.push_back(t); - } - if (!c.request.empty()) lock.targets.push_back({ c.request, c.identity, c.provider }); - std::ranges::sort(lock.targets, {}, &mcpp::lockfile::LockedTarget::request); - } + const auto priorTarget = lock_targets(state, lock); if (!lock.packages.empty() || !lock.indices.empty()) { auto lockPath = state.workRoot / "mcpp.lock"; // `--locked` ASSERTS THAT THIS RESOLUTION IS THE RECORDED ONE. @@ -459,12 +475,7 @@ std::expected step13_lockfile(PrepareState& state, BuildConte for (auto const& p : prior->packages) was[key(p)] = p.version; for (auto const& p : lock.packages) now[key(p)] = p.version; std::vector drift; - if (auto const& c = state.targetChoice; priorTarget && priorTarget->identity != c.identity) - drift.push_back(std::format( - "target {}: {} ({}) -> {} ({}){}", c.request, priorTarget->identity, - priorTarget->provider.empty() ? "the platform's" : priorTarget->provider, - c.identity, c.provider.empty() ? "the platform's" : c.provider, - c.why.empty() ? std::string{} : "; " + c.why)); + if (auto d = target_drift(state, priorTarget)) drift.push_back(std::move(*d)); for (auto const& [k, v] : now) { auto it = was.find(k); if (it == was.end()) drift.push_back(k + " " + v + " (not in the lock)"); From 7d3d48bcbadfa88fbda9a50240d1d30c841b88da Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 16:49:20 +0900 Subject: [PATCH 41/45] fix: a graph's C library leads a Mach-O image's link The loader of Mach-O runs an image's initialisers before its entry, in link order; openkal-musl is brought up by an initialiser of its own, and the program's objects came before it. A global constructor that allocated (the registration of an mtest case) stopped in the allocator before the library was up: every os test on hosted macOS exited 139 at start. The objects of a package that provides mcpp:c-abi now lead every Mach-O image's objects, in their own order, as a C runtime's start objects do on ELF (e2e 1053). Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 5 + docs/35-platforms.md | 6 ++ docs/zh/35-platforms.md | 4 + src/build/plan.cppm | 40 ++++++-- ...c_library_is_up_before_its_initialisers.sh | 98 +++++++++++++++++++ 5 files changed, 143 insertions(+), 10 deletions(-) create mode 100755 tests/e2e/1053_a_mach_o_images_c_library_is_up_before_its_initialisers.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b13887c..c6f0f62fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,11 @@ `unaffected`, and the check that every script runs somewhere accepts them for that run; with main's coverage map in place, it had read them as uncovered. A shard the set gives no script is not a failure of mtest. +- A C library of the graph (`mcpp:c-abi`, openkal-musl) leads a Mach-O image's + link. The loader runs initialisers in link order before the entry, and the + program's objects came first: a global constructor that allocated (an mtest + case's registration) stopped in openkal-musl's allocator before the library + was up, on hosted macOS (e2e 1053). - e2e 298 picks a row whose compiler on this host is its convention's: a macOS host serves `aarch64-linux-musl` through openkal-platform, whose compiler is llvm, and there llvm alone overrides nothing. diff --git a/docs/35-platforms.md b/docs/35-platforms.md index 0f3a49d74..d14989174 100644 --- a/docs/35-platforms.md +++ b/docs/35-platforms.md @@ -160,6 +160,12 @@ On Windows the arrangement is the GNU ABI's row (`--target x86_64-windows-gnu`); a triple outside `[platform.hosted] targets` is refused, naming the row of the same system and architecture that it serves. +A C library of the graph (`mcpp:c-abi`) leads a Mach-O image's link +(2026.11.9.1+). The loader of that format runs an image's initialisers before +its entry, in link order, and the C library is brought up by an initialiser of +its own; with its objects first, it is up before any global constructor of the +program runs, as a C runtime's start objects precede a program on ELF. + ## The version group `[platform.versions]` names one version of each package of a family. From mcpp diff --git a/docs/zh/35-platforms.md b/docs/zh/35-platforms.md index 55946aaa3..e84d6e0d6 100644 --- a/docs/zh/35-platforms.md +++ b/docs/zh/35-platforms.md @@ -137,6 +137,10 @@ platform = "openkal-platform@0.24.0" Windows 上这一安排对应 GNU ABI 的行(`--target x86_64-windows-gnu`);`[platform.hosted] targets` 之外的 三元组被拒绝,消息给出它所服务的同系统同架构的行。 +图中的 C 库(`mcpp:c-abi`)位于 Mach-O 映像链接的最前(2026.11.9.1+)。该格式的加载器在入口之前按链接顺序运行 +映像的初始化函数,而 C 库由它自己的一个初始化函数启动;其目标文件在前,C 库便在程序的任何全局构造函数运行之前 +就绪,如同 ELF 上 C 运行时的启动目标文件位于程序之前。 + ## 版本组 `[platform.versions]` 为一个家族列出每个包的一个版本。从 mcpp 2026.11.7.1 起由版本组选择版本:平台是依赖图解析的 diff --git a/src/build/plan.cppm b/src/build/plan.cppm index c320b4450..fceb6604c 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -4569,16 +4569,36 @@ make_plan(const mcpp::manifest::Manifest& manifest, // unit's objects, a workspace's member archives held its closure: 11 433 // members for 2 666 objects, 5.3 GB (mcpp#874). An object that is not a // compile unit's -- an `object` action's -- is the package's own and stays. - { - std::map ownerOfObject; - for (auto const& cu : plan.compileUnits) ownerOfObject.emplace(cu.object, cu.packageName); - for (auto& lu : plan.linkUnits) { - if (lu.kind != LinkUnit::StaticLibrary) continue; - std::erase_if(lu.objects, [&](const std::filesystem::path& o) { - auto it = ownerOfObject.find(o); - return it != ownerOfObject.end() && it->second != lu.package; - }); - } + std::map ownerOfObject; + for (auto const& cu : plan.compileUnits) ownerOfObject.emplace(cu.object, cu.packageName); + for (auto& lu : plan.linkUnits) { + if (lu.kind != LinkUnit::StaticLibrary) continue; + std::erase_if(lu.objects, [&](const std::filesystem::path& o) { + auto it = ownerOfObject.find(o); + return it != ownerOfObject.end() && it->second != lu.package; + }); + } + // THE C LIBRARY LEADS A MACH-O IMAGE. This format's loader runs an image's + // initialisers before its entry, in link order, so a C library of the + // graph (`mcpp:c-abi`, openkal-musl) is brought up by an initialiser of + // its own (openkal-musl's port/src/mach/early_init.c). Linked after the + // program's objects, it came too late: the registration of a test, a + // global constructor that allocates, stopped in an allocator not yet + // started (mcpplibs/os#22, hosted macOS). Its objects precede every other + // package's, in their own order, as a C runtime's start objects do on ELF. + if (targetTriple.is_mach_o()) { + std::set cLibraries; + for (auto const& pkg : packages) + if (std::ranges::any_of(pkg.manifest.provides, + [](const std::string& p) { return p.starts_with("mcpp:c-abi"); })) + cLibraries.insert(qualified_package_name(pkg.manifest)); + auto of_c_library = [&](const std::filesystem::path& o) { + auto it = ownerOfObject.find(o); + return it != ownerOfObject.end() && cLibraries.contains(it->second); + }; + if (!cLibraries.empty()) + for (auto& lu : plan.linkUnits) + if (lu.kind != LinkUnit::StaticLibrary) std::ranges::stable_partition(lu.objects, of_c_library); } for (auto& v : artifactVariants) plan.compileUnits.push_back(std::move(v)); diff --git a/tests/e2e/1053_a_mach_o_images_c_library_is_up_before_its_initialisers.sh b/tests/e2e/1053_a_mach_o_images_c_library_is_up_before_its_initialisers.sh new file mode 100755 index 000000000..0b26c3bac --- /dev/null +++ b/tests/e2e/1053_a_mach_o_images_c_library_is_up_before_its_initialisers.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# requires: llvm unix-shell +# 1053 -- a Mach-O image's C library is up before the program's initialisers +# (2026.11.9.1). +# +# The loader of this format runs an image's initialisers before its entry, in +# the order the linker placed their objects. A C library of the graph +# (`mcpp:c-abi`: openkal-musl, in openkal-platform's hosted arrangement) is +# brought up by an initialiser of its own; the program's objects came first on +# the link line, and a global constructor that allocated -- the registration of +# an mtest case -- stopped in an allocator not yet started (mcpplibs/os#22, +# hosted macOS, EXC_BAD_ACCESS in mimalloc). The C library's objects now lead +# the link, as a C runtime's start objects do on ELF. +# +# Criteria: +# A. In the image built for `aarch64-macos-musl` over the hosted arrangement, the +# C library's initialiser (`okm_early_init`, openkal-musl's +# port/src/mach/early_init.c) precedes the program's own. +# B. On an arm64 macOS host the program runs, its global constructor +# allocating before `main`. +set -e +source "$(dirname "${BASH_SOURCE[0]}")/_toolchain_env.sh" + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +mkdir -p "$TMP/app/src" +cd "$TMP/app" +printf '[package]\nname = "early"\nversion = "0.1.0"\n' > mcpp.toml +cat > src/main.cpp <<'CPP' +import std; +// A global constructor that allocates, as a test framework's registration does. +std::vector* registered = new std::vector{std::string(64, 'x')}; +int main() { + std::println("registered {}", registered->front().size()); + return 0; +} +CPP + +"$MCPP" build --target aarch64-macos-musl > build.log 2>&1 || fail "the hosted macOS build" build.log +grep -q "openkal-platform" build.log || { + echo "SKIP: aarch64-macos-musl is not served by openkal-platform's hosted arrangement here" + exit 0 +} +bin=$(find target -path '*/bin/early' -type f | head -1) +[ -n "$bin" ] || fail "no image was produced" build.log + +# A +objdump="$_e2e_registry_base/xim-x-llvm/${LLVM_VERSION}/bin/llvm-objdump" +nm="$_e2e_registry_base/xim-x-llvm/${LLVM_VERSION}/bin/llvm-nm" +[ -x "$objdump" ] && [ -x "$nm" ] || { echo "SKIP(A): no llvm@${LLVM_VERSION} payload to read the image with"; objdump=""; } +if [ -n "$objdump" ]; then + python3 - "$bin" "$objdump" "$nm" > a.log 2>&1 <<'PY' || fail "A: the order of the image's initialisers" a.log build.log +import subprocess, sys +image, objdump, nm = sys.argv[1:4] +dump = subprocess.run([objdump, "--macho", "-s", "-j", "__init_offsets", image], + capture_output=True, text=True, check=True).stdout +words = [] +for line in dump.splitlines(): + parts = line.split() + if len(parts) < 2 or not all(c in "0123456789abcdef" for c in parts[0]): + continue + for w in parts[1:5]: + if len(w) == 8 and all(c in "0123456789abcdef" for c in w): + words.append(int.from_bytes(bytes.fromhex(w), "little")) +symbols = [] +for line in subprocess.run([nm, "-n", image], capture_output=True, text=True, check=True).stdout.splitlines(): + parts = line.split() + if len(parts) == 3: + symbols.append((int(parts[0], 16), parts[2])) +def name_at(offset): + address = 0x100000000 + offset + best = None + for value, name in symbols: + if value > address: + break + best = name + return best or "?" +names = [name_at(o) for o in words] +print("initialisers:", names[:8]) +assert any("okm_early_init" in n for n in names), "the C library's initialiser is not in the image" +early = next(i for i, n in enumerate(names) if "okm_early_init" in n) +program = [i for i, n in enumerate(names) if "main.cpp" in n] +assert program, "the program's initialiser is not in the image" +assert early < program[0], f"the program's initialiser runs at {program[0]}, before the C library's at {early}" +PY + echo " ok the C library's initialiser precedes the program's" +fi + +# B +if [ "$(uname -s)-$(uname -m)" = Darwin-arm64 ]; then + "$bin" > b.log 2>&1 || fail "B: the image ended with $?" b.log + grep -q "registered 64" b.log || fail "B: the program's output" b.log + echo " ok the program runs, its constructor allocating before main" +fi + +echo "OK: a Mach-O image's C library is up before its initialisers" From 55d5ad5cc84f1519117feb4313183065764c5e6a Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 17:40:42 +0900 Subject: [PATCH 42/45] test: e2e 298 reads the driver's family and declares its skip when no row is picked compiler.family names the driver's family (clang), and the candidate loop ended on a failed test under set -e: a macOS host, whose musl rows are served by openkal-platform, ended the script without its skip. Measured by lowering musl-gcc's priority on Linux, which serves those rows as macOS does. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../298_overriding_a_convention_requires_replacing_it.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/e2e/298_overriding_a_convention_requires_replacing_it.sh b/tests/e2e/298_overriding_a_convention_requires_replacing_it.sh index 412e2bf9d..d3d4ae54e 100644 --- a/tests/e2e/298_overriding_a_convention_requires_replacing_it.sh +++ b/tests/e2e/298_overriding_a_convention_requires_replacing_it.sh @@ -92,14 +92,14 @@ hostArch="$("$MCPP" toolchain list --format json 2>/dev/null \ # openkal-platform, whose compiler is llvm, on a macOS host. There llvm alone is # the row's own service, and nothing was removed. The compiler the selection # takes with no compiler stated is that service, so a candidate counts only -# where it is not llvm. +# where it is not llvm (`compiler.family` names the driver's family: `clang`). mkdir -p "$work/neutral" printf '[package]\nname = "neutral"\nversion = "0.1.0"\n' > "$work/neutral/mcpp.toml" served_by_its_convention() { local fam fam="$(cd "$work/neutral" && "$MCPP" why toolchain --target "$1" --format json 2>/dev/null \ | jq -r '.data.compiler.family // ""' | tr -d '\r')" - [ -n "$fam" ] && [ "$fam" != llvm ] + [ -n "$fam" ] && [ "$fam" != llvm ] && [ "$fam" != clang ] } pick() { local t @@ -110,8 +110,9 @@ pick() { | select(.status != "planned") | select($a == "" or (.target | startswith($a + "-"))) | .target] | unique[]' | tr -d '\r'); do - served_by_its_convention "$t" && { echo "$t"; return; } + served_by_its_convention "$t" && { echo "$t"; return 0; } done + return 0 } pinned="$(pick "$hostArch")" [ -n "$pinned" ] || pinned="$(pick "")" From e976f94ee120a8eacf337ad2f061cec1cc4d90ef Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 18:34:59 +0900 Subject: [PATCH 43/45] fix: a paired provider this host lacks is host-cannot-serve; the matrix states the target model's cells The target matrix scanned this release for the first time on the four hosts. Its changed cells are the target model's: musl identities served by openkal-platform where no other provider is (llvm builds them, gcc and msvc are capability-pin, the graph mode's pinned family is outside the platform's version group), windows-gnu with llvm paired with the MinGW-w64 payload, and the i686-none-elf row. One cell was a defect: windows-gnu with llvm on a host without the MinGW payload failed its install as an unnamed refusal; it is now host-cannot-serve. e2e 1044 runs in the job that has wine. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/workflows/cross-build-test.yml | 8 ++ CHANGELOG.md | 5 + src/build/prepare/toolchain_decision.cpp | 21 ++++ tests/matrix/expected.tsv | 127 +++++++++++++---------- 4 files changed, 109 insertions(+), 52 deletions(-) diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 709cd4dc6..867b9ded7 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -359,6 +359,14 @@ jobs: export MCPP_VENDORED_XLINGS="$XLINGS_BIN" bash tests/e2e/102_mingw_cross_wine.sh + # SPEC-016 §6: clang with the MinGW-w64 payload as its sysroot, run + # under wine. Named here for the reason 102 is: `# requires: mingw-cross + # wine`, which no Linux shard has. + - name: "e2e: clang pairs with the MinGW payload" + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + bash tests/e2e/1044_clang_pairs_with_the_mingw_payload.sh + # mcpp#365. This is the only job with a MinGW cross toolchain, so it is # the only place the GNU half of resource compilation (windres -O coff, # because GNU ld cannot consume a .res) can run at all — the Linux e2e diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f0f62fa..5a6649611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,11 @@ program's objects came first: a global constructor that allocated (an mtest case's registration) stopped in openkal-musl's allocator before the library was up, on hosted macOS (e2e 1053). +- `x86_64-windows-gnu` with llvm on a host the MinGW-w64 payload does not + serve (linux-aarch64, macOS) is refused as `host-cannot-serve`, as with gcc; + the paired payload's failed install was an unnamed refusal. +- The target matrix's expected table states the cells the target model + changes, measured on the four hosts; e2e 1044 runs in the job that has wine. - e2e 298 picks a row whose compiler on this host is its convention's: a macOS host serves `aarch64-linux-musl` through openkal-platform, whose compiler is llvm, and there llvm alone overrides nothing. diff --git a/src/build/prepare/toolchain_decision.cpp b/src/build/prepare/toolchain_decision.cpp index d3a4fa5a9..abb8a6258 100644 --- a/src/build/prepare/toolchain_decision.cpp +++ b/src/build/prepare/toolchain_decision.cpp @@ -438,6 +438,27 @@ std::expected phase5_toolchain_after_graph(PrepareState& stat && std::ranges::find(p->pairs, mcpp::toolchain::family_name(declared->family)) != p->pairs.end(); }); + // A PAIRED PROVIDER THIS HOST DOES NOT HAVE SUPPLIES NOTHING. The + // selection took it as the first candidate of all, none being + // available (§4.6); with the toolchain stated, the diagnosis of an + // unserved target was not composed, and the payload's install + // failed as an unnamed refusal (target matrix, linux-aarch64). + auto const& choice = state.targetChoice; + const bool pairedHere = std::ranges::any_of(choice.candidates, [&](auto const& k) { + return k.available && k.provider && k.provider->name == choice.provider + && k.row && k.row->triple == choice.identity; + }); + if (paired && !pairedHere) { + refusal::record(refusal::Code::HostCannotServe); + return std::unexpected(std::format( + "target '{}' with {} takes its system from the '{}' provider's {} payload, " + "which this host does not have.\n" + " `mcpp why target --target {}` lists the providers of the target and " + "where each is available; depend on a package that implements the " + "target's system to build it here.", + state.targetRowName, *state.tcSpec, choice.provider, state.targetRowPin, + state.targetRowName)); + } if (paired) state.pairedSystem = state.targetRowPin; else if (declared && rowTc && declared->family != rowTc->family) { refusal::record(refusal::Code::ConventionUnreplaced); diff --git a/tests/matrix/expected.tsv b/tests/matrix/expected.tsv index 44a21b15c..9b6e34ca6 100644 --- a/tests/matrix/expected.tsv +++ b/tests/matrix/expected.tsv @@ -44,14 +44,26 @@ # 所以在 x86_64 上够得着、在 aarch64 上够不着。只写 `linux` 会让后跑的一台把先跑 # 的那台在这张表里的行「解释掉」,而覆盖的方向取决于谁后跑,不取决于谁对。 # -# 全部四台:2026-10-08 由 ci-target-matrix.yml 实测(mcpp 源码 e862c657)。 -# 每一行来自对应宿主,两种体系分别测量。记录运行 37761833670。 +# 全部四台:2026-11-09 由 ci-target-matrix.yml 实测(mcpp 2026.11.9.1 候选,源码 55d5ad5c)。 +# 每一行来自对应宿主,两种体系分别测量。记录运行 42581749209。 # # 各台格数: -# linux-x86_64 70 gcc + llvm(payload 48 / graph 22) -# linux-aarch64 66 gcc + llvm(payload 46 / graph 20) -# macos-arm64 38 llvm(payload 27 / graph 11) -# windows-x86_64 70 llvm + msvc@system(payload 48 / graph 22) +# linux-x86_64 80 gcc + llvm(payload 54 / graph 26) +# linux-aarch64 76 gcc + llvm(payload 52 / graph 24) +# macos-arm64 47 llvm(payload 32 / graph 15) +# windows-x86_64 80 llvm + msvc@system(payload 54 / graph 26) +# +# 2026.11.9.1 的目标模型(SPEC-016)改变的格: +# - 一个系统的 musl 身份,在本机没有其他提供者时由 openkal-platform 服务(优先级 50): +# 非 macOS 宿主上的 `x86_64-macos`(补全为 `x86_64-macos-musl`)、`*-windows-musl`、 +# aarch64 Linux 上的 `x86_64-linux-musl`、macOS 与 Windows 上的 Linux musl 行, +# payload 体系以 llvm 建出;gcc 与 msvc 是对平台工具链(能力陈述)的推翻,`capability-pin`。 +# - 同一批行在 graph 体系里由平台服务,图里钉的 openkal-musl 0.3.5 与 openkal-llvm-runtime +# 0.1.3 在平台版本组之外,`platform-version-group`;依赖图供给 musl 的 `aarch64-macos` +# 身份随实际 C 库取 `aarch64-macos-musl`(Q5),同理。 +# - `x86_64-windows-gnu` 配 llvm:MinGW-w64 载荷与 clang 配对(§6),有载荷的宿主建出; +# 没有该载荷的宿主是 `host-cannot-serve`,与 gcc 的格同一条规则。 +# - `i686-none-elf` 是新行:llvm 建出,gcc 与 msvc 是 `capability-pin`。 # # 中间五列是报告器输出,不参与支持判定。现有报告器把 aarch64-ios 的 # c-abi 输出为 glibc(payload),这不代表 iOS 产物实际链接 glibc。Apple @@ -71,15 +83,15 @@ graph linux-aarch64 wasm32-emscripten llvm@23.1.3 - - - - - unsupported capabili graph linux-aarch64 x86_64-linux-android gcc@16.1.0 - - - - - unsupported capability-pin graph linux-aarch64 x86_64-linux-android llvm@23.1.3 - - - - - unsupported capability-pin graph linux-aarch64 x86_64-linux-musl gcc@16.1.0 - - - - - unsupported host-cannot-serve -graph linux-aarch64 x86_64-linux-musl llvm@23.1.3 x86_64-unknown-linux-musl payload musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none -graph linux-aarch64 x86_64-macos gcc@16.1.0 - - - - - unsupported tier-planned -graph linux-aarch64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned +graph linux-aarch64 x86_64-linux-musl llvm@23.1.3 - - - - - unsupported platform-version-group +graph linux-aarch64 x86_64-macos gcc@16.1.0 - - - - - unsupported capability-pin +graph linux-aarch64 x86_64-macos llvm@23.1.3 - - - - - unsupported platform-version-group graph linux-aarch64 x86_64-openkal gcc@16.1.0 - - - - - unsupported capability-pin graph linux-aarch64 x86_64-openkal llvm@23.1.3 - - - - - unsupported platform-version-group graph linux-aarch64 x86_64-windows-gnu gcc@16.1.0 - - - - - unsupported host-cannot-serve graph linux-aarch64 x86_64-windows-gnu llvm@23.1.3 x86_64-w64-windows-gnu none musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph linux-aarch64 x86_64-windows-musl gcc@16.1.0 - - - - - unsupported capability-pin -graph linux-aarch64 x86_64-windows-musl llvm@23.1.3 x86_64-w64-windows-gnu payload musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none +graph linux-aarch64 x86_64-windows-musl llvm@23.1.3 - - - - - unsupported platform-version-group graph linux-x86_64 aarch64-linux-android gcc@16.1.0 - - - - - unsupported capability-pin graph linux-x86_64 aarch64-linux-android llvm@23.1.3 - - - - - unsupported capability-pin graph linux-x86_64 aarch64-linux-gnu gcc@16.1.0 - - - - - unsupported layer-requirement @@ -98,32 +110,34 @@ graph linux-x86_64 x86_64-linux-gnu gcc@16.1.0 - - - - - unsupported layer-requi graph linux-x86_64 x86_64-linux-gnu llvm@23.1.3 x86_64-unknown-linux-gnu payload musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph linux-x86_64 x86_64-linux-musl gcc@16.1.0 - - - - - unsupported layer-requirement graph linux-x86_64 x86_64-linux-musl llvm@23.1.3 x86_64-unknown-linux-musl payload musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none -graph linux-x86_64 x86_64-macos gcc@16.1.0 - - - - - unsupported tier-planned -graph linux-x86_64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned +graph linux-x86_64 x86_64-macos gcc@16.1.0 - - - - - unsupported capability-pin +graph linux-x86_64 x86_64-macos llvm@23.1.3 - - - - - unsupported platform-version-group graph linux-x86_64 x86_64-openkal gcc@16.1.0 - - - - - unsupported capability-pin graph linux-x86_64 x86_64-openkal llvm@23.1.3 - - - - - unsupported platform-version-group graph linux-x86_64 x86_64-windows-gnu gcc@16.1.0 - - - - - unsupported layer-requirement graph linux-x86_64 x86_64-windows-gnu llvm@23.1.3 x86_64-w64-windows-gnu none musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph linux-x86_64 x86_64-windows-musl gcc@16.1.0 - - - - - unsupported capability-pin -graph linux-x86_64 x86_64-windows-musl llvm@23.1.3 x86_64-w64-windows-gnu payload musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none +graph linux-x86_64 x86_64-windows-musl llvm@23.1.3 - - - - - unsupported platform-version-group graph macos-arm64 aarch64-linux-android llvm@23.1.3 - - - - - unsupported capability-pin graph macos-arm64 aarch64-linux-gnu llvm@23.1.3 aarch64-unknown-linux-gnu host musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none -graph macos-arm64 aarch64-linux-musl llvm@23.1.3 aarch64-unknown-linux-musl host musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none -graph macos-arm64 aarch64-macos llvm@23.1.3 arm64-apple-macos14.0 host musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none +graph macos-arm64 aarch64-linux-musl llvm@23.1.3 - - - - - unsupported platform-version-group +graph macos-arm64 aarch64-macos llvm@23.1.3 - - - - - unsupported platform-version-group +graph macos-arm64 aarch64-macos-musl llvm@23.1.3 - - - - - unsupported platform-version-group graph macos-arm64 aarch64-openkal llvm@23.1.3 - - - - - unsupported platform-version-group graph macos-arm64 riscv64-linux-musl llvm@23.1.3 - - - - - unsupported tier-planned graph macos-arm64 wasm32-emscripten llvm@23.1.3 - - - - - unsupported capability-pin graph macos-arm64 x86_64-linux-android llvm@23.1.3 - - - - - unsupported capability-pin -graph macos-arm64 x86_64-linux-musl llvm@23.1.3 x86_64-unknown-linux-musl host musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none -graph macos-arm64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned +graph macos-arm64 x86_64-linux-musl llvm@23.1.3 - - - - - unsupported platform-version-group +graph macos-arm64 x86_64-macos llvm@23.1.3 - - - - - unsupported platform-version-group +graph macos-arm64 x86_64-macos-musl llvm@23.1.3 - - - - - unsupported platform-version-group graph macos-arm64 x86_64-openkal llvm@23.1.3 - - - - - unsupported platform-version-group graph macos-arm64 x86_64-windows-gnu llvm@23.1.3 x86_64-w64-windows-gnu none musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none -graph macos-arm64 x86_64-windows-musl llvm@23.1.3 x86_64-w64-windows-gnu host musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none +graph macos-arm64 x86_64-windows-musl llvm@23.1.3 - - - - - unsupported platform-version-group graph windows-x86_64 aarch64-linux-android llvm@23.1.3 - - - - - unsupported capability-pin graph windows-x86_64 aarch64-linux-android msvc@system - - - - - unsupported capability-pin graph windows-x86_64 aarch64-linux-gnu llvm@23.1.3 aarch64-unknown-linux-gnu none musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph windows-x86_64 aarch64-linux-gnu msvc@system - - - - - unsupported host-tool-toolchain -graph windows-x86_64 aarch64-linux-musl llvm@23.1.3 aarch64-unknown-linux-musl none musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none +graph windows-x86_64 aarch64-linux-musl llvm@23.1.3 - - - - - unsupported platform-version-group graph windows-x86_64 aarch64-linux-musl msvc@system - - - - - unsupported host-tool-toolchain graph windows-x86_64 aarch64-openkal llvm@23.1.3 - - - - - unsupported platform-version-group graph windows-x86_64 aarch64-openkal msvc@system - - - - - unsupported capability-pin @@ -135,15 +149,15 @@ graph windows-x86_64 x86_64-linux-android llvm@23.1.3 - - - - - unsupported capa graph windows-x86_64 x86_64-linux-android msvc@system - - - - - unsupported capability-pin graph windows-x86_64 x86_64-linux-musl llvm@23.1.3 x86_64-unknown-linux-musl none musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph windows-x86_64 x86_64-linux-musl msvc@system - - - - - unsupported host-tool-toolchain -graph windows-x86_64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned -graph windows-x86_64 x86_64-macos msvc@system - - - - - unsupported tier-planned +graph windows-x86_64 x86_64-macos llvm@23.1.3 - - - - - unsupported platform-version-group +graph windows-x86_64 x86_64-macos msvc@system - - - - - unsupported capability-pin graph windows-x86_64 x86_64-openkal llvm@23.1.3 - - - - - unsupported platform-version-group graph windows-x86_64 x86_64-openkal msvc@system - - - - - unsupported capability-pin graph windows-x86_64 x86_64-windows-gnu llvm@23.1.3 x86_64-w64-windows-gnu none musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none graph windows-x86_64 x86_64-windows-gnu msvc@system - - - - - unsupported host-tool-toolchain graph windows-x86_64 x86_64-windows-msvc llvm@23.1.3 - - - - - unsupported std-module-precompile graph windows-x86_64 x86_64-windows-msvc msvc@system - - - - - unsupported host-tool-toolchain -graph windows-x86_64 x86_64-windows-musl llvm@23.1.3 x86_64-w64-windows-gnu none musl(graph) libc++(graph) openkal-llvm-runtime@0.1.3 ok none +graph windows-x86_64 x86_64-windows-musl llvm@23.1.3 - - - - - unsupported platform-version-group graph windows-x86_64 x86_64-windows-musl msvc@system - - - - - unsupported capability-pin payload linux-aarch64 aarch64-linux-android gcc@16.1.0 - - - - - unsupported capability-pin payload linux-aarch64 aarch64-linux-android llvm@23.1.3 - - - - - unsupported capability-pin @@ -154,11 +168,13 @@ payload linux-aarch64 aarch64-linux-musl llvm@23.1.3 - - - - - unsupported conve payload linux-aarch64 aarch64-none-elf gcc@16.1.0 - - - - - unsupported capability-pin payload linux-aarch64 aarch64-none-elf llvm@23.1.3 aarch64-none-elf payload - - - ok none payload linux-aarch64 aarch64-openkal gcc@16.1.0 - - - - - unsupported capability-pin -payload linux-aarch64 aarch64-openkal llvm@23.1.3 aarch64-unknown-none-elf payload musl(platform) libc++(platform) openkal-musl@0.22.3 ok none +payload linux-aarch64 aarch64-openkal llvm@23.1.3 aarch64-unknown-none-elf payload musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload linux-aarch64 armv7a-none-eabi gcc@16.1.0 - - - - - unsupported capability-pin payload linux-aarch64 armv7a-none-eabi llvm@23.1.3 armv7a-none-eabi payload - - - ok none payload linux-aarch64 armv7a-none-eabihf gcc@16.1.0 - - - - - unsupported capability-pin payload linux-aarch64 armv7a-none-eabihf llvm@23.1.3 armv7a-none-eabihf payload - - - ok none +payload linux-aarch64 i686-none-elf gcc@16.1.0 - - - - - unsupported capability-pin +payload linux-aarch64 i686-none-elf llvm@23.1.3 i686-none-elf payload - - - ok none payload linux-aarch64 riscv32-none-elf gcc@16.1.0 - - - - - unsupported capability-pin payload linux-aarch64 riscv32-none-elf llvm@23.1.3 riscv32-none-elf payload - - - ok none payload linux-aarch64 riscv64-linux-musl gcc@16.1.0 - - - - - unsupported tier-planned @@ -184,17 +200,17 @@ payload linux-aarch64 wasm32-emscripten llvm@23.1.3 - - - - - unsupported capabi payload linux-aarch64 x86_64-linux-android gcc@16.1.0 - - - - - unsupported capability-pin payload linux-aarch64 x86_64-linux-android llvm@23.1.3 - - - - - unsupported capability-pin payload linux-aarch64 x86_64-linux-musl gcc@16.1.0 - - - - - unsupported host-cannot-serve -payload linux-aarch64 x86_64-linux-musl llvm@23.1.3 - - - - - unsupported convention-unreplaced -payload linux-aarch64 x86_64-macos gcc@16.1.0 - - - - - unsupported tier-planned -payload linux-aarch64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned +payload linux-aarch64 x86_64-linux-musl llvm@23.1.3 x86_64-unknown-linux-musl payload musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none +payload linux-aarch64 x86_64-macos gcc@16.1.0 - - - - - unsupported capability-pin +payload linux-aarch64 x86_64-macos llvm@23.1.3 x86_64-apple-macos14.0 payload musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload linux-aarch64 x86_64-none-elf gcc@16.1.0 - - - - - unsupported capability-pin payload linux-aarch64 x86_64-none-elf llvm@23.1.3 x86_64-none-elf payload - - - ok none payload linux-aarch64 x86_64-openkal gcc@16.1.0 - - - - - unsupported capability-pin -payload linux-aarch64 x86_64-openkal llvm@23.1.3 x86_64-unknown-none-elf payload musl(platform) libc++(platform) openkal-musl@0.22.3 ok none +payload linux-aarch64 x86_64-openkal llvm@23.1.3 x86_64-unknown-none-elf payload musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload linux-aarch64 x86_64-windows-gnu gcc@16.1.0 - - - - - unsupported host-cannot-serve -payload linux-aarch64 x86_64-windows-gnu llvm@23.1.3 - - - - - unsupported convention-unreplaced +payload linux-aarch64 x86_64-windows-gnu llvm@23.1.3 - - - - - unsupported host-cannot-serve payload linux-aarch64 x86_64-windows-musl gcc@16.1.0 - - - - - unsupported capability-pin -payload linux-aarch64 x86_64-windows-musl llvm@23.1.3 - - - - - unsupported host-cannot-serve +payload linux-aarch64 x86_64-windows-musl llvm@23.1.3 x86_64-w64-windows-gnu payload musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload linux-x86_64 aarch64-linux-android gcc@16.1.0 - - - - - unsupported capability-pin payload linux-x86_64 aarch64-linux-android llvm@23.1.3 - - - - - unsupported capability-pin payload linux-x86_64 aarch64-linux-gnu gcc@16.1.0 - - - - - unsupported convention-unreplaced @@ -204,11 +220,13 @@ payload linux-x86_64 aarch64-linux-musl llvm@23.1.3 - - - - - unsupported conven payload linux-x86_64 aarch64-none-elf gcc@16.1.0 - - - - - unsupported capability-pin payload linux-x86_64 aarch64-none-elf llvm@23.1.3 aarch64-none-elf payload - - - ok none payload linux-x86_64 aarch64-openkal gcc@16.1.0 - - - - - unsupported capability-pin -payload linux-x86_64 aarch64-openkal llvm@23.1.3 aarch64-unknown-none-elf payload musl(platform) libc++(platform) openkal-musl@0.22.3 ok none +payload linux-x86_64 aarch64-openkal llvm@23.1.3 aarch64-unknown-none-elf payload musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload linux-x86_64 armv7a-none-eabi gcc@16.1.0 - - - - - unsupported capability-pin payload linux-x86_64 armv7a-none-eabi llvm@23.1.3 armv7a-none-eabi payload - - - ok none payload linux-x86_64 armv7a-none-eabihf gcc@16.1.0 - - - - - unsupported capability-pin payload linux-x86_64 armv7a-none-eabihf llvm@23.1.3 armv7a-none-eabihf payload - - - ok none +payload linux-x86_64 i686-none-elf gcc@16.1.0 - - - - - unsupported capability-pin +payload linux-x86_64 i686-none-elf llvm@23.1.3 i686-none-elf payload - - - ok none payload linux-x86_64 riscv32-none-elf gcc@16.1.0 - - - - - unsupported capability-pin payload linux-x86_64 riscv32-none-elf llvm@23.1.3 riscv32-none-elf payload - - - ok none payload linux-x86_64 riscv64-linux-musl gcc@16.1.0 - - - - - unsupported tier-planned @@ -237,26 +255,28 @@ payload linux-x86_64 x86_64-linux-gnu gcc@16.1.0 x86_64-unknown-linux-gnu subos payload linux-x86_64 x86_64-linux-gnu llvm@23.1.3 x86_64-unknown-linux-gnu payload glibc(payload) libc++(payload) - ok none payload linux-x86_64 x86_64-linux-musl gcc@16.1.0 x86_64-unknown-linux-musl payload musl(payload) libstdc++(payload) - ok none payload linux-x86_64 x86_64-linux-musl llvm@23.1.3 - - - - - unsupported convention-unreplaced -payload linux-x86_64 x86_64-macos gcc@16.1.0 - - - - - unsupported tier-planned -payload linux-x86_64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned +payload linux-x86_64 x86_64-macos gcc@16.1.0 - - - - - unsupported capability-pin +payload linux-x86_64 x86_64-macos llvm@23.1.3 x86_64-apple-macos14.0 payload musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload linux-x86_64 x86_64-none-elf gcc@16.1.0 - - - - - unsupported capability-pin payload linux-x86_64 x86_64-none-elf llvm@23.1.3 x86_64-none-elf payload - - - ok none payload linux-x86_64 x86_64-openkal gcc@16.1.0 - - - - - unsupported capability-pin -payload linux-x86_64 x86_64-openkal llvm@23.1.3 x86_64-unknown-none-elf payload musl(platform) libc++(platform) openkal-musl@0.22.3 ok none +payload linux-x86_64 x86_64-openkal llvm@23.1.3 x86_64-unknown-none-elf payload musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload linux-x86_64 x86_64-windows-gnu gcc@16.1.0 x86_64-w64-windows-gnu none ucrt(payload) libstdc++(payload) - ok none -payload linux-x86_64 x86_64-windows-gnu llvm@23.1.3 - - - - - unsupported convention-unreplaced +payload linux-x86_64 x86_64-windows-gnu llvm@23.1.3 x86_64-w64-windows-gnu none ucrt(payload) libstdc++(payload) - ok none payload linux-x86_64 x86_64-windows-musl gcc@16.1.0 - - - - - unsupported capability-pin -payload linux-x86_64 x86_64-windows-musl llvm@23.1.3 - - - - - unsupported host-cannot-serve -payload macos-arm64 aarch64-ios llvm@23.1.3 arm64-apple-ios17.5 host glibc(payload) libc++(payload) - ok none -payload macos-arm64 aarch64-ios-sim llvm@23.1.3 arm64-apple-ios17.5-simulator host sim(payload) libc++(payload) - ok none +payload linux-x86_64 x86_64-windows-musl llvm@23.1.3 x86_64-w64-windows-gnu payload musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none +payload macos-arm64 aarch64-ios llvm@23.1.3 arm64-apple-ios18.5 host glibc(payload) libc++(payload) - ok none +payload macos-arm64 aarch64-ios-sim llvm@23.1.3 arm64-apple-ios18.5-simulator host sim(payload) libc++(payload) - ok none payload macos-arm64 aarch64-linux-android llvm@23.1.3 - - - - - unsupported capability-pin payload macos-arm64 aarch64-linux-gnu llvm@23.1.3 - - - - - unsupported host-cannot-serve -payload macos-arm64 aarch64-linux-musl llvm@23.1.3 - - - - - unsupported convention-unreplaced +payload macos-arm64 aarch64-linux-musl llvm@23.1.3 aarch64-unknown-linux-musl host musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload macos-arm64 aarch64-macos llvm@23.1.3 arm64-apple-macos14.0 host libSystem(payload) libc++(payload) - ok none +payload macos-arm64 aarch64-macos-musl llvm@23.1.3 arm64-apple-macos14.0 host musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload macos-arm64 aarch64-none-elf llvm@23.1.3 aarch64-none-elf host - - - ok none -payload macos-arm64 aarch64-openkal llvm@23.1.3 aarch64-unknown-none-elf payload musl(platform) libc++(platform) openkal-musl@0.22.3 ok none +payload macos-arm64 aarch64-openkal llvm@23.1.3 aarch64-unknown-none-elf host musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload macos-arm64 armv7a-none-eabi llvm@23.1.3 armv7a-none-eabi host - - - ok none payload macos-arm64 armv7a-none-eabihf llvm@23.1.3 armv7a-none-eabihf host - - - ok none +payload macos-arm64 i686-none-elf llvm@23.1.3 i686-none-elf host - - - ok none payload macos-arm64 riscv32-none-elf llvm@23.1.3 riscv32-none-elf host - - - ok none payload macos-arm64 riscv64-linux-musl llvm@23.1.3 - - - - - unsupported tier-planned payload macos-arm64 riscv64-none-elf llvm@23.1.3 riscv64-none-elf host - - - ok none @@ -268,28 +288,31 @@ payload macos-arm64 thumbv8m.base-none-eabi llvm@23.1.3 thumbv8m.base-none-eabi payload macos-arm64 thumbv8m.main-none-eabi llvm@23.1.3 thumbv8m.main-none-eabi host - - - ok none payload macos-arm64 thumbv8m.main-none-eabihf llvm@23.1.3 thumbv8m.main-none-eabihf host - - - ok none payload macos-arm64 wasm32-emscripten llvm@23.1.3 - - - - - unsupported capability-pin -payload macos-arm64 x86_64-ios-sim llvm@23.1.3 x86_64-apple-ios17.5-simulator host sim(payload) libc++(payload) - ok none +payload macos-arm64 x86_64-ios-sim llvm@23.1.3 x86_64-apple-ios18.5-simulator host sim(payload) libc++(payload) - ok none payload macos-arm64 x86_64-linux-android llvm@23.1.3 - - - - - unsupported capability-pin -payload macos-arm64 x86_64-linux-musl llvm@23.1.3 - - - - - unsupported convention-unreplaced -payload macos-arm64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned +payload macos-arm64 x86_64-linux-musl llvm@23.1.3 x86_64-unknown-linux-musl host musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none +payload macos-arm64 x86_64-macos llvm@23.1.3 x86_64-apple-macos14.0 host musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none +payload macos-arm64 x86_64-macos-musl llvm@23.1.3 x86_64-apple-macos14.0 host musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload macos-arm64 x86_64-none-elf llvm@23.1.3 x86_64-none-elf host - - - ok none -payload macos-arm64 x86_64-openkal llvm@23.1.3 x86_64-unknown-none-elf payload musl(platform) libc++(platform) openkal-musl@0.22.3 ok none -payload macos-arm64 x86_64-windows-gnu llvm@23.1.3 - - - - - unsupported convention-unreplaced -payload macos-arm64 x86_64-windows-musl llvm@23.1.3 - - - - - unsupported host-cannot-serve +payload macos-arm64 x86_64-openkal llvm@23.1.3 x86_64-unknown-none-elf host musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none +payload macos-arm64 x86_64-windows-gnu llvm@23.1.3 - - - - - unsupported host-cannot-serve +payload macos-arm64 x86_64-windows-musl llvm@23.1.3 x86_64-w64-windows-gnu host musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload windows-x86_64 aarch64-linux-android llvm@23.1.3 - - - - - unsupported capability-pin payload windows-x86_64 aarch64-linux-android msvc@system - - - - - unsupported capability-pin payload windows-x86_64 aarch64-linux-gnu llvm@23.1.3 - - - - - unsupported host-cannot-serve payload windows-x86_64 aarch64-linux-gnu msvc@system - - - - - unsupported convention-unreplaced -payload windows-x86_64 aarch64-linux-musl llvm@23.1.3 - - - - - unsupported convention-unreplaced +payload windows-x86_64 aarch64-linux-musl llvm@23.1.3 aarch64-unknown-linux-musl none musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload windows-x86_64 aarch64-linux-musl msvc@system - - - - - unsupported convention-unreplaced payload windows-x86_64 aarch64-none-elf llvm@23.1.3 aarch64-none-elf none - - - ok none payload windows-x86_64 aarch64-none-elf msvc@system - - - - - unsupported capability-pin -payload windows-x86_64 aarch64-openkal llvm@23.1.3 aarch64-unknown-none-elf payload musl(platform) libc++(platform) openkal-musl@0.22.3 ok none +payload windows-x86_64 aarch64-openkal llvm@23.1.3 aarch64-unknown-none-elf none musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload windows-x86_64 aarch64-openkal msvc@system - - - - - unsupported capability-pin payload windows-x86_64 armv7a-none-eabi llvm@23.1.3 armv7a-none-eabi none - - - ok none payload windows-x86_64 armv7a-none-eabi msvc@system - - - - - unsupported capability-pin payload windows-x86_64 armv7a-none-eabihf llvm@23.1.3 armv7a-none-eabihf none - - - ok none payload windows-x86_64 armv7a-none-eabihf msvc@system - - - - - unsupported capability-pin +payload windows-x86_64 i686-none-elf llvm@23.1.3 - - - - - unsupported lld-required-absent +payload windows-x86_64 i686-none-elf msvc@system - - - - - unsupported capability-pin payload windows-x86_64 riscv32-none-elf llvm@23.1.3 riscv32-none-elf none - - - ok none payload windows-x86_64 riscv32-none-elf msvc@system - - - - - unsupported capability-pin payload windows-x86_64 riscv64-linux-musl llvm@23.1.3 - - - - - unsupported tier-planned @@ -316,15 +339,15 @@ payload windows-x86_64 x86_64-linux-android llvm@23.1.3 - - - - - unsupported ca payload windows-x86_64 x86_64-linux-android msvc@system - - - - - unsupported capability-pin payload windows-x86_64 x86_64-linux-musl llvm@23.1.3 - - - - - unsupported convention-unreplaced payload windows-x86_64 x86_64-linux-musl msvc@system - - - - - unsupported convention-unreplaced -payload windows-x86_64 x86_64-macos llvm@23.1.3 - - - - - unsupported tier-planned -payload windows-x86_64 x86_64-macos msvc@system - - - - - unsupported tier-planned +payload windows-x86_64 x86_64-macos llvm@23.1.3 x86_64-apple-macos14.0 none musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none +payload windows-x86_64 x86_64-macos msvc@system - - - - - unsupported capability-pin payload windows-x86_64 x86_64-none-elf llvm@23.1.3 - - - - - unsupported lld-required-absent payload windows-x86_64 x86_64-none-elf msvc@system - - - - - unsupported capability-pin -payload windows-x86_64 x86_64-openkal llvm@23.1.3 x86_64-unknown-none-elf payload musl(platform) libc++(platform) openkal-musl@0.22.3 ok none +payload windows-x86_64 x86_64-openkal llvm@23.1.3 x86_64-unknown-none-elf none musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload windows-x86_64 x86_64-openkal msvc@system - - - - - unsupported capability-pin -payload windows-x86_64 x86_64-windows-gnu llvm@23.1.3 - - - - - unsupported convention-unreplaced +payload windows-x86_64 x86_64-windows-gnu llvm@23.1.3 x86_64-w64-windows-gnu none ucrt(payload) libstdc++(payload) - ok none payload windows-x86_64 x86_64-windows-gnu msvc@system - - - - - unsupported convention-unreplaced payload windows-x86_64 x86_64-windows-msvc llvm@23.1.3 x86_64-pc-windows-msvc none msvc(payload) msvc-stl(payload) - ok none payload windows-x86_64 x86_64-windows-msvc msvc@system x86_64-pc-windows-msvc none msvc(payload) (payload) - ok none -payload windows-x86_64 x86_64-windows-musl llvm@23.1.3 - - - - - unsupported host-cannot-serve +payload windows-x86_64 x86_64-windows-musl llvm@23.1.3 x86_64-w64-windows-gnu none musl(platform) libc++(platform) openkal-llvm-runtime@0.19.2 ok none payload windows-x86_64 x86_64-windows-musl msvc@system - - - - - unsupported capability-pin From 37b2ac1752829dca2f5148d2289e3f46f7be7ccd Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 18:59:26 +0900 Subject: [PATCH 44/45] chore: mcpp's tests and its skeleton take mtest 0.5.2 The ecosystem's mtest is 0.5.2 (children, mcpplibs.os ^0.4.1); the e2e runner was already 0.5.2. mcpp's unit tests pass on it (166 programs). Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 4 ++-- docs/05-dependencies.md | 4 ++-- docs/zh/05-dependencies.md | 4 ++-- mcpp.toml | 2 +- modules/buildmcpp/mcpp.toml | 2 +- modules/graph/mcpp.toml | 2 +- modules/manifest/mcpp.toml | 2 +- modules/toolchain-model/mcpp.toml | 2 +- modules/versioning/mcpp.toml | 2 +- src/build/test_protocol.cppm | 2 +- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a6649611..5bd2c846d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,8 +92,8 @@ - `mcpp pack` with several programs packs the one named after the package, else the first by name as before, and names the others with `mcpp pack ` and `--all-programs`. -- mcpp's own unit tests are mtest cases (mtest 0.5.1, mcpplibs.os 0.4.1); a - skeleton's tests use mtest 0.5.1. +- mcpp's own unit tests are mtest cases (mtest 0.5.2, mcpplibs.os 0.4.1); a + skeleton's tests use mtest 0.5.2. ### Fixed diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index a7184cbfd..2c66524c1 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -67,8 +67,8 @@ not deprecated: ```toml [dev-dependencies] -mcpplibs.mtest = "0.5.1" -"mcpplibs.mtest.gtest" = "0.5.1" +mcpplibs.mtest = "0.5.2" +"mcpplibs.mtest.gtest" = "0.5.2" ``` ```toml diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 2f0ed0de6..7c83a09e6 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -62,8 +62,8 @@ mcpp 把它读作这些点所拼写的选择器,警告该拼法已弃用(自 ```toml [dev-dependencies] -mcpplibs.mtest = "0.5.1" -"mcpplibs.mtest.gtest" = "0.5.1" +mcpplibs.mtest = "0.5.2" +"mcpplibs.mtest.gtest" = "0.5.2" ``` ```toml diff --git a/mcpp.toml b/mcpp.toml index 16f79ec68..1f52bd5dd 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -165,7 +165,7 @@ members = [ # `mcpp build` ignores [dev-dependencies]; only `mcpp test` resolves them. [dev-dependencies] -mcpplibs.mtest = "0.5.1" +mcpplibs.mtest = "0.5.2" # Everything else uses M5.0 conventions: # - target = mcpp (kind=bin inferred from src/main.cpp) diff --git a/modules/buildmcpp/mcpp.toml b/modules/buildmcpp/mcpp.toml index 428ccb463..db89e2db3 100644 --- a/modules/buildmcpp/mcpp.toml +++ b/modules/buildmcpp/mcpp.toml @@ -41,4 +41,4 @@ mcpp.source-kind = { path = "../source-kind" } mcpp.toolchain-model = { path = "../toolchain-model" } [dev-dependencies] -mcpplibs.mtest = "0.5.1" +mcpplibs.mtest = "0.5.2" diff --git a/modules/graph/mcpp.toml b/modules/graph/mcpp.toml index 3213a296b..ca92d5875 100644 --- a/modules/graph/mcpp.toml +++ b/modules/graph/mcpp.toml @@ -17,4 +17,4 @@ description = "Algorithms over index-addressed directed graphs: topological orde license = "Apache-2.0" [dev-dependencies] -mcpplibs.mtest = "0.5.1" +mcpplibs.mtest = "0.5.2" diff --git a/modules/manifest/mcpp.toml b/modules/manifest/mcpp.toml index e7540d9c6..21d52fd13 100644 --- a/modules/manifest/mcpp.toml +++ b/modules/manifest/mcpp.toml @@ -30,4 +30,4 @@ mcpplibs.os = "0.4.1" # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. [dev-dependencies] -mcpplibs.mtest = "0.5.1" +mcpplibs.mtest = "0.5.2" diff --git a/modules/toolchain-model/mcpp.toml b/modules/toolchain-model/mcpp.toml index 2dc3d0b3a..097c55ace 100644 --- a/modules/toolchain-model/mcpp.toml +++ b/modules/toolchain-model/mcpp.toml @@ -29,7 +29,7 @@ mcpplibs.os = "0.4.1" # state its contract in isolation; the root `tests/unit/` states the facts two # layers must agree on. [dev-dependencies] -mcpplibs.mtest = "0.5.1" +mcpplibs.mtest = "0.5.2" # build.mcpp compiles data/builtin.toml (the built-in target rows and providers) # into a module of static tables, with mcpp's own TOML parser. diff --git a/modules/versioning/mcpp.toml b/modules/versioning/mcpp.toml index f7d5b060b..64dcffe88 100644 --- a/modules/versioning/mcpp.toml +++ b/modules/versioning/mcpp.toml @@ -25,4 +25,4 @@ license = "Apache-2.0" # state its contract in isolation; the root `tests/unit/` states the same facts # as the engine consumes them, and the pair is the cross-check. [dev-dependencies] -mcpplibs.mtest = "0.5.1" +mcpplibs.mtest = "0.5.2" diff --git a/src/build/test_protocol.cppm b/src/build/test_protocol.cppm index 2eed5584a..f936bebec 100644 --- a/src/build/test_protocol.cppm +++ b/src/build/test_protocol.cppm @@ -18,7 +18,7 @@ inline constexpr int kVersion = 1; // The framework mcpp scaffolds tests with and suggests where a package has // none (docs/01, docs/08): mtest, by this release, from its repository. -inline constexpr std::string_view kMtestRelease = "0.5.1"; +inline constexpr std::string_view kMtestRelease = "0.5.2"; inline constexpr std::string_view kMtestRepo = "https://github.com/mcpp-community/mtest"; // The protocol's variables, removed from the environment a program inherits From aa64db14cf9bd9d99c164475129ebb3b9ba0f287 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Mon, 9 Nov 2026 19:38:30 +0900 Subject: [PATCH 45/45] refactor: the convention's decision leaves phase5_toolchain_after_graph The refusal of a paired provider the host lacks took the phase function past the 400-line gate of #722; the decision on a declared compiler against the row's convention is a function of its own. Behaviour is unchanged. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- src/build/prepare/toolchain_decision.cpp | 179 ++++++++++++----------- 1 file changed, 92 insertions(+), 87 deletions(-) diff --git a/src/build/prepare/toolchain_decision.cpp b/src/build/prepare/toolchain_decision.cpp index abb8a6258..ace2e45eb 100644 --- a/src/build/prepare/toolchain_decision.cpp +++ b/src/build/prepare/toolchain_decision.cpp @@ -68,6 +68,97 @@ bool graph_supplies_system(const PrepareState& state) { } // namespace +// OVERRIDING THE CONVENTION IS ALLOWED; OVERRIDING IT AND SUPPLYING +// NOTHING IN ITS PLACE IS NOT, AND UNTIL THIS BLOCK IT LOOKED THE SAME. +// +// A hosted row's pin names the payload that supplies the target's C +// library. A project may name a different compiler — that is the escape +// hatch the whole convention/capability distinction exists to protect — +// and the ordinary reason to do so is that its dependency graph supplies +// the C library instead. `examples/06-openkal-cross` is exactly that: +// `llvm@22.1.8` plus `openkal-llvm-runtime`, and `graphSuppliesSystem` +// is true there. +// +// WITH NEITHER, THE BUILD USED TO RUN ANYWAY AND FAIL SOMEWHERE ELSE. +// Measured 2026-08-26 on Linux, `[toolchain] default = "llvm@22.1.8"` +// and no dependencies: +// +// --target x86_64-linux-musl +// hermetic link check failed … crtbeginT.o (bare name) +// --target x86_64-windows-gnu +// hermetic link check failed … +// /usr/lib/gcc/x86_64-w64-mingw32/13-win32/crtbegin.o (outside) +// +// Both are accurate about the symptom and silent about the decision: +// clang is retargetable and brings no C library, so it reached for a +// gcc installation — one that does not exist under the payload prefix +// in the first case, and that belongs to the HOST in the second. There +// is no llvm payload supplying either target's C library today. +// +// THE REFUSAL IS DECIDED HERE BECAUSE ONLY HERE ARE BOTH HALVES +// KNOWN. The row is read a thousand lines earlier and the graph does +// not exist then; `host_can_serve` is family-agnostic and would answer +// "yes, some payload here produces it" — the same shape as the family +// this release is about, a predicate answering a question narrower than +// the one it is asked. +static std::expected decide_convention(PrepareState& state, bool graphSuppliesSystem) { + if (!state.targetRowPin.empty() && !graphSuppliesSystem + && tc_origin_is_user_explicit(state.tcOrigin) && state.tcSpec.has_value()) { + auto declared = mcpp::toolchain::parse_toolchain_spec(*state.tcSpec); + auto rowTc = mcpp::toolchain::parse_toolchain_spec(state.targetRowPin); + // A provider that pairs with the declared family supplies the system + // with it (SPEC-016 §6): the row's payload becomes that compiler's. + const bool paired = declared && rowTc && declared->family != rowTc->family + && std::ranges::any_of(mcpp::toolchain::target::providers(), [&](auto const* p) { + return p->name == state.targetChoice.provider + && mcpp::toolchain::target::serves(*p, state.targetRowName) + && std::ranges::find(p->pairs, mcpp::toolchain::family_name(declared->family)) + != p->pairs.end(); + }); + // A PAIRED PROVIDER THIS HOST DOES NOT HAVE SUPPLIES NOTHING. The + // selection took it as the first candidate of all, none being + // available (§4.6); with the toolchain stated, the diagnosis of an + // unserved target was not composed, and the payload's install + // failed as an unnamed refusal (target matrix, linux-aarch64). + auto const& choice = state.targetChoice; + const bool pairedHere = std::ranges::any_of(choice.candidates, [&](auto const& k) { + return k.available && k.provider && k.provider->name == choice.provider + && k.row && k.row->triple == choice.identity; + }); + if (paired && !pairedHere) { + refusal::record(refusal::Code::HostCannotServe); + return std::unexpected(std::format( + "target '{}' with {} takes its system from the '{}' provider's {} payload, " + "which this host does not have.\n" + " `mcpp why target --target {}` lists the providers of the target and " + "where each is available; depend on a package that implements the " + "target's system to build it here.", + state.targetRowName, *state.tcSpec, choice.provider, state.targetRowPin, + state.targetRowName)); + } + if (paired) state.pairedSystem = state.targetRowPin; + else if (declared && rowTc && declared->family != rowTc->family) { + refusal::record(refusal::Code::ConventionUnreplaced); + return std::unexpected(std::format( + "target '{}' takes its C library from the '{}' payload, and " + "'{}' has none here.\n" + " The row's toolchain is a convention, so naming your " + "own compiler overrides it —\n" + " but the convention is what supplies this target's " + "headers and C library, and\n" + " nothing in the dependency graph supplies them " + "instead.\n" + " depend on a package that implements the target's C " + "library (openkal-musl and\n" + " openkal-llvm-runtime are the ones in the index), or " + "remove the `[toolchain]`\n" + " line so `{}` is used for this target.", + state.targetRowName, state.targetRowPin, *state.tcSpec, state.targetRowPin)); + } + } + return {}; +} + std::expected phase5_toolchain_after_graph(PrepareState& state) { // ─── The toolchain, resolved now that the graph exists ────────────────── @@ -392,93 +483,7 @@ std::expected phase5_toolchain_after_graph(PrepareState& stat } } } - // OVERRIDING THE CONVENTION IS ALLOWED; OVERRIDING IT AND SUPPLYING - // NOTHING IN ITS PLACE IS NOT, AND UNTIL THIS BLOCK IT LOOKED THE SAME. - // - // A hosted row's pin names the payload that supplies the target's C - // library. A project may name a different compiler — that is the escape - // hatch the whole convention/capability distinction exists to protect — - // and the ordinary reason to do so is that its dependency graph supplies - // the C library instead. `examples/06-openkal-cross` is exactly that: - // `llvm@22.1.8` plus `openkal-llvm-runtime`, and `graphSuppliesSystem` - // is true there. - // - // WITH NEITHER, THE BUILD USED TO RUN ANYWAY AND FAIL SOMEWHERE ELSE. - // Measured 2026-08-26 on Linux, `[toolchain] default = "llvm@22.1.8"` - // and no dependencies: - // - // --target x86_64-linux-musl - // hermetic link check failed … crtbeginT.o (bare name) - // --target x86_64-windows-gnu - // hermetic link check failed … - // /usr/lib/gcc/x86_64-w64-mingw32/13-win32/crtbegin.o (outside) - // - // Both are accurate about the symptom and silent about the decision: - // clang is retargetable and brings no C library, so it reached for a - // gcc installation — one that does not exist under the payload prefix - // in the first case, and that belongs to the HOST in the second. There - // is no llvm payload supplying either target's C library today. - // - // THE REFUSAL IS DECIDED HERE BECAUSE ONLY HERE ARE BOTH HALVES - // KNOWN. The row is read a thousand lines earlier and the graph does - // not exist then; `host_can_serve` is family-agnostic and would answer - // "yes, some payload here produces it" — the same shape as the family - // this release is about, a predicate answering a question narrower than - // the one it is asked. - if (!state.targetRowPin.empty() && !graphSuppliesSystem - && tc_origin_is_user_explicit(state.tcOrigin) && state.tcSpec.has_value()) { - auto declared = mcpp::toolchain::parse_toolchain_spec(*state.tcSpec); - auto rowTc = mcpp::toolchain::parse_toolchain_spec(state.targetRowPin); - // A provider that pairs with the declared family supplies the system - // with it (SPEC-016 §6): the row's payload becomes that compiler's. - const bool paired = declared && rowTc && declared->family != rowTc->family - && std::ranges::any_of(mcpp::toolchain::target::providers(), [&](auto const* p) { - return p->name == state.targetChoice.provider - && mcpp::toolchain::target::serves(*p, state.targetRowName) - && std::ranges::find(p->pairs, mcpp::toolchain::family_name(declared->family)) - != p->pairs.end(); - }); - // A PAIRED PROVIDER THIS HOST DOES NOT HAVE SUPPLIES NOTHING. The - // selection took it as the first candidate of all, none being - // available (§4.6); with the toolchain stated, the diagnosis of an - // unserved target was not composed, and the payload's install - // failed as an unnamed refusal (target matrix, linux-aarch64). - auto const& choice = state.targetChoice; - const bool pairedHere = std::ranges::any_of(choice.candidates, [&](auto const& k) { - return k.available && k.provider && k.provider->name == choice.provider - && k.row && k.row->triple == choice.identity; - }); - if (paired && !pairedHere) { - refusal::record(refusal::Code::HostCannotServe); - return std::unexpected(std::format( - "target '{}' with {} takes its system from the '{}' provider's {} payload, " - "which this host does not have.\n" - " `mcpp why target --target {}` lists the providers of the target and " - "where each is available; depend on a package that implements the " - "target's system to build it here.", - state.targetRowName, *state.tcSpec, choice.provider, state.targetRowPin, - state.targetRowName)); - } - if (paired) state.pairedSystem = state.targetRowPin; - else if (declared && rowTc && declared->family != rowTc->family) { - refusal::record(refusal::Code::ConventionUnreplaced); - return std::unexpected(std::format( - "target '{}' takes its C library from the '{}' payload, and " - "'{}' has none here.\n" - " The row's toolchain is a convention, so naming your " - "own compiler overrides it —\n" - " but the convention is what supplies this target's " - "headers and C library, and\n" - " nothing in the dependency graph supplies them " - "instead.\n" - " depend on a package that implements the target's C " - "library (openkal-musl and\n" - " openkal-llvm-runtime are the ones in the index), or " - "remove the `[toolchain]`\n" - " line so `{}` is used for this target.", - state.targetRowName, state.targetRowPin, *state.tcSpec, state.targetRowPin)); - } - } + if (auto r = decide_convention(state, graphSuppliesSystem); !r) return r; if (auto r = state.resolve_target_toolchain(); !r) return std::unexpected(r.error()); }