feat(nonlinear-static-newton-raphson): step 0 - cli-output-lease-and-derived-names

This commit is contained in:
KOKO\Mimi
2026-08-24 10:42:14 +09:00
parent 41816c8aef
commit fc16ef6221
61 changed files with 10890 additions and 115 deletions
+1
View File
@@ -9,6 +9,7 @@ add_executable(
unit/assembly/parallel_for_test.cpp
unit/assembly/load_assembler_test.cpp
unit/assembly/sparse_assembler_test.cpp
unit/app/output_paths_test.cpp
unit/constraints/boundary_condition_test.cpp
unit/constraints/essential_constraints_test.cpp
unit/core/ascii_test.cpp
+89 -33
View File
@@ -1,5 +1,7 @@
#include "fesa/app/fesa_application.h"
#include "fesa/app/output_paths.h"
#include <algorithm>
#include <gtest/gtest.h>
#include <hdf5.h>
@@ -9,6 +11,7 @@
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <stdexcept>
#include <string>
#include <utility>
@@ -332,32 +335,37 @@ void ExpectDiagnosticFieldOrder(const std::string& text) {
}
}
std::vector<std::string> ExplicitOutputArguments(
const std::filesystem::path& input, const std::filesystem::path& output) {
// FesaApplication receives argv[0]-excluded operands and options.
return {input.string(), "--output", output.string()};
struct FileSnapshot {
std::string contents;
std::filesystem::file_time_type write_time;
};
FileSnapshot SnapshotFile(const std::filesystem::path& path) {
std::ifstream stream{path, std::ios::binary};
if (!stream) {
throw std::runtime_error{"Unable to open an app output fixture."};
}
return {{std::istreambuf_iterator<char>{stream},
std::istreambuf_iterator<char>{}},
std::filesystem::last_write_time(path)};
}
} // namespace
TEST(LinearStaticCli, DefaultAndExplicitOutputProduceFesaHdf5) {
// NSNR-CLI-008: the application no longer uses a CWD-global results.h5.
TEST(LinearStaticCli, DefaultOutputUsesInputSiblingDerivedName) {
TempDirectory directory{"paths"};
const auto input = directory.Path() / "model.inp";
const auto input = directory.Path() / "Beam.Model.INP";
WriteText(input, AxialDeck(true, false, false));
const auto default_output = directory.Path() / "results.h5";
const auto default_output = directory.Path() / "Beam.Model_results.h5";
{
CurrentDirectoryGuard current_directory{directory.Path()};
const auto result = RunApplication({input.string()});
ASSERT_EQ(result.exit_code, 0) << result.standard_error;
}
ExpectFesaHdf5Identity(default_output, input);
const auto explicit_output = directory.Path() / "named-output.h5";
const auto result =
RunApplication(ExplicitOutputArguments(input, explicit_output));
ASSERT_EQ(result.exit_code, 0) << result.standard_error;
ExpectFesaHdf5Identity(explicit_output, input);
EXPECT_FALSE(std::filesystem::exists(directory.Path() / "results.h5"));
}
TEST(LinearStaticCli, ReturnsEveryExactExitCodeAndOrderedDiagnostic) {
@@ -369,8 +377,7 @@ TEST(LinearStaticCli, ReturnsEveryExactExitCodeAndOrderedDiagnostic) {
WriteText(model_input, AxialDeck(true, true, false));
WriteText(solver_input, AxialDeck(false, false, false));
const auto success = RunApplication(
ExplicitOutputArguments(valid_input, directory.Path() / "success.h5"));
const auto success = RunApplication({valid_input.string()});
const auto usage = RunApplication({});
const auto missing_input = directory.Path() / "missing.inp";
const auto input = RunApplication({missing_input.string()});
@@ -378,25 +385,19 @@ TEST(LinearStaticCli, ReturnsEveryExactExitCodeAndOrderedDiagnostic) {
RunApplication({missing_input.string(), "--output", "--output"});
const auto unknown_output_option =
RunApplication({missing_input.string(), "--output", "--bogus"});
const auto model = RunApplication(ExplicitOutputArguments(
model_input, directory.Path() / "model-failure.h5"));
const auto solver = RunApplication(ExplicitOutputArguments(
solver_input, directory.Path() / "solver-failure.h5"));
const auto output = RunApplication(ExplicitOutputArguments(
valid_input, directory.Path() / "nonexistent-parent" / "results.h5"));
const auto model = RunApplication({model_input.string()});
const auto solver = RunApplication({solver_input.string()});
EXPECT_EQ(success.exit_code, 0) << success.standard_error;
EXPECT_EQ(usage.exit_code, 2);
EXPECT_EQ(input.exit_code, 3);
EXPECT_EQ(model.exit_code, 4);
EXPECT_EQ(solver.exit_code, 5);
EXPECT_EQ(output.exit_code, 6);
ExpectDiagnosticFieldOrder(usage.standard_error);
ExpectDiagnosticFieldOrder(input.standard_error);
ExpectDiagnosticFieldOrder(model.standard_error);
ExpectDiagnosticFieldOrder(solver.standard_error);
ExpectDiagnosticFieldOrder(output.standard_error);
EXPECT_EQ(repeated_output.exit_code, 2);
ExpectDiagnosticFieldOrder(repeated_output.standard_error);
EXPECT_NE(repeated_output.standard_error.find("code=cli-usage"),
@@ -407,24 +408,79 @@ TEST(LinearStaticCli, ReturnsEveryExactExitCodeAndOrderedDiagnostic) {
std::string::npos);
}
// NSNR-CLI-003 and NSNR-CLI-007: usage failure precedes every output side
// effect, including replacement of existing derived files.
TEST(LinearStaticCli, OutputSubstringRejectionLeavesDerivedFilesUnchanged) {
TempDirectory directory{"usage-no-output"};
const auto input = directory.Path() / "model.inp";
const auto results = directory.Path() / "model_results.h5";
const auto log = directory.Path() / "model_log.txt";
WriteText(input, AxialDeck(true, false, false));
WriteText(results, "previous-results");
WriteText(log, "previous-log");
const FileSnapshot previous_results = SnapshotFile(results);
const FileSnapshot previous_log = SnapshotFile(log);
for (const char* argument :
{"abc--outputdef", "--output-path", "--output=foo", "--output"}) {
const auto result = RunApplication({input.string(), argument});
EXPECT_EQ(result.exit_code, 2) << argument;
EXPECT_EQ(
std::count(result.standard_error.begin(), result.standard_error.end(), '\n'),
1U)
<< result.standard_error;
EXPECT_NE(result.standard_error.find("code=cli-usage"),
std::string::npos);
EXPECT_EQ(SnapshotFile(results).contents, previous_results.contents);
EXPECT_EQ(SnapshotFile(results).write_time, previous_results.write_time);
EXPECT_EQ(SnapshotFile(log).contents, previous_log.contents);
EXPECT_EQ(SnapshotFile(log).write_time, previous_log.write_time);
}
}
// NSNR-CLI-006: a held lease blocks the application before any public output
// is opened, truncated, or replaced.
TEST(LinearStaticCli, BusyOutputLeaseLeavesDerivedFilesUnchanged) {
TempDirectory directory{"busy-output"};
const auto input = directory.Path() / "model.inp";
const auto results = directory.Path() / "model_results.h5";
const auto log = directory.Path() / "model_log.txt";
WriteText(input, AxialDeck(true, false, false));
WriteText(results, "previous-results");
WriteText(log, "previous-log");
const FileSnapshot previous_results = SnapshotFile(results);
const FileSnapshot previous_log = SnapshotFile(log);
const fesa::DerivedOutputPaths paths{input, results, log};
auto lease = fesa::AcquireOutputWriteLease(paths);
ASSERT_TRUE(lease.HasValue()) << lease.GetStatus().Diagnostics().size();
const auto result = RunApplication({input.string()});
EXPECT_EQ(result.exit_code, 6) << result.standard_error;
EXPECT_NE(result.standard_error.find("code=output-busy"),
std::string::npos);
EXPECT_EQ(SnapshotFile(results).contents, previous_results.contents);
EXPECT_EQ(SnapshotFile(results).write_time, previous_results.write_time);
EXPECT_EQ(SnapshotFile(log).contents, previous_log.contents);
EXPECT_EQ(SnapshotFile(log).write_time, previous_log.write_time);
}
TEST(LinearStaticCli, OutputRequestsDoNotFilterMandatoryResults) {
TempDirectory directory{"output-requests"};
const auto plain_input = directory.Path() / "plain.inp";
const auto requested_input = directory.Path() / "requested.inp";
const auto plain_output = directory.Path() / "plain.h5";
const auto requested_output = directory.Path() / "requested.h5";
WriteText(plain_input, AxialDeck(true, false, false));
WriteText(requested_input, AxialDeck(true, false, true));
const auto plain =
RunApplication(ExplicitOutputArguments(plain_input, plain_output));
const auto requested = RunApplication(
ExplicitOutputArguments(requested_input, requested_output));
const auto plain = RunApplication({plain_input.string()});
const auto requested = RunApplication({requested_input.string()});
ASSERT_EQ(plain.exit_code, 0) << plain.standard_error;
ASSERT_EQ(requested.exit_code, 0) << requested.standard_error;
const auto plain_file = OpenFile(plain_output);
const auto requested_file = OpenFile(requested_output);
const auto plain_file =
OpenFile(plain_input.parent_path() / "plain_results.h5");
const auto requested_file =
OpenFile(requested_input.parent_path() / "requested_results.h5");
ExpectMandatoryInventory(plain_file.Get());
ExpectMandatoryInventory(requested_file.Get());
@@ -449,10 +505,10 @@ TEST(LinearStaticCli, OutputRequestsDoNotFilterMandatoryResults) {
TEST(Mitc4ShellCli, WritesShellHdf5ThroughTheExistingApplicationRoute) {
TempDirectory directory{"shell-route"};
const auto input = directory.Path() / "shell.inp";
const auto output = directory.Path() / "shell-results.h5";
const auto output = directory.Path() / "shell_results.h5";
WriteText(input, AllConstrainedShellDeck());
const auto result = RunApplication(ExplicitOutputArguments(input, output));
const auto result = RunApplication({input.string()});
ASSERT_EQ(result.exit_code, 0) << result.standard_error;
ExpectShellHdf5Identity(output, input);
}
@@ -153,7 +153,8 @@ TEST(B33ReferenceComparison, GeneratesAuthoritativeHdf5AndComparisonEvidence) {
const auto input = reference_directory / "cantilever beam.inp";
const auto output_directory =
binary_root / "reference" / "cantilever-beam-b33";
const auto results = output_directory / "results.h5";
const auto execution_input = output_directory / input.filename();
const auto results = output_directory / "cantilever beam_results.h5";
const auto comparison = output_directory / "comparison.json";
// Only the exact build-local evidence directory is reset; the approved
@@ -173,16 +174,18 @@ TEST(B33ReferenceComparison, GeneratesAuthoritativeHdf5AndComparisonEvidence) {
std::filesystem::path{"cantilever beam reactions.csv"});
EXPECT_EQ(reference_before[3U].relative_path,
std::filesystem::path{"cantilever beam.inp"});
ASSERT_TRUE(std::filesystem::copy_file(input, execution_input, error));
ASSERT_FALSE(error);
fesa::FesaApplication application;
// FesaApplication receives application operands/options; main strips argv[0].
ASSERT_EQ(application.Run({input.string(), "--output", results.string()}), 0);
ASSERT_EQ(application.Run({execution_input.string()}), 0);
ASSERT_TRUE(std::filesystem::is_regular_file(results));
ASSERT_GT(H5Fis_hdf5(results.string().c_str()), 0);
EXPECT_GT(StressRowCount(results), 0U);
auto comparison_result =
fesa::test::ReferenceComparison::Compare(results, reference_directory);
fesa::test::ReferenceComparison::Compare(results, reference_directory,
execution_input);
ASSERT_TRUE(comparison_result.HasValue());
const auto& report = comparison_result.Value();
ASSERT_TRUE(report.passed);
@@ -248,7 +251,9 @@ TEST(B33ReferenceComparison, GeneratesAuthoritativeHdf5AndComparisonEvidence) {
}
std::sort(generated_names.begin(), generated_names.end());
EXPECT_EQ(generated_names,
(std::vector<std::string>{"comparison.json", "results.h5"}));
(std::vector<std::string>{"cantilever beam.inp",
"cantilever beam_results.h5",
"comparison.json"}));
ExpectTreeUnchanged(reference_before, SnapshotTree(reference_directory));
}
+11 -4
View File
@@ -75,14 +75,19 @@ CaseEvidence RunCase(const std::string& case_id,
if (!std::filesystem::create_directories(output_directory, error) || error) {
throw std::runtime_error{"Unable to create MITC4 evidence directory."};
}
const auto results = output_directory / "results.h5";
const auto execution_input = output_directory / input.filename();
if (!std::filesystem::copy_file(input, execution_input, error) || error) {
throw std::runtime_error{"Unable to copy MITC4 input for execution."};
}
const auto results = output_directory /
(execution_input.stem().string() + "_results.h5");
const auto comparison = output_directory / "comparison.json";
fesa::FesaApplication application;
EXPECT_EQ(application.Run({input.string(), "--output", results.string()}), 0);
EXPECT_EQ(application.Run({execution_input.string()}), 0);
EXPECT_TRUE(std::filesystem::is_regular_file(results));
auto comparison_result = fesa::test::Mitc4ReferenceComparison::Compare(
{case_id, source_element_type, input, csv, results});
{case_id, source_element_type, execution_input, csv, results});
if (!comparison_result.HasValue()) {
std::string diagnostics;
for (const auto& diagnostic : comparison_result.GetStatus().Diagnostics()) {
@@ -104,7 +109,9 @@ CaseEvidence RunCase(const std::string& case_id,
}
std::sort(generated.begin(), generated.end());
EXPECT_EQ(generated,
(std::vector<std::string>{"comparison.json", "results.h5"}));
(std::vector<std::string>{"comparison.json",
input.filename().string(),
results.filename().string()}));
EXPECT_TRUE(std::filesystem::is_directory(reference_directory));
ExpectUnchanged(input, input_before);
ExpectUnchanged(csv, csv_before);
+12 -3
View File
@@ -1345,10 +1345,18 @@ bool FiniteReport(const ComparisonReport& report) {
Result<ComparisonReport> ReferenceComparison::Compare(
const std::filesystem::path& results_hdf5,
const std::filesystem::path& legacy_reference_directory) {
return Compare(results_hdf5, legacy_reference_directory,
legacy_reference_directory / kInputName);
}
Result<ComparisonReport> ReferenceComparison::Compare(
const std::filesystem::path& results_hdf5,
const std::filesystem::path& legacy_reference_directory,
const std::filesystem::path& execution_input_path) {
try {
RequireExactArtifactInventory(legacy_reference_directory);
const auto input = legacy_reference_directory / kInputName;
Domain domain = ReadApprovedDomain(input);
const auto reference_input = legacy_reference_directory / kInputName;
Domain domain = ReadApprovedDomain(reference_input);
const ReferenceTable displacement =
ReadReferenceCsv(legacy_reference_directory / kDisplacementName,
{"Frame", "Part Instance Name", "Node Label", "U-U1",
@@ -1359,7 +1367,8 @@ Result<ComparisonReport> ReferenceComparison::Compare(
"RF-RF2", "RF-RF3", "RM-RM1", "RM-RM2", "RM-RM3"});
const SectionReferenceTable section =
ReadSectionReferenceCsv(legacy_reference_directory / kSectionName);
HdfProjection hdf = ReadHdfProjection(results_hdf5, input, domain);
HdfProjection hdf =
ReadHdfProjection(results_hdf5, execution_input_path, domain);
const auto displacement_rows = OrderedRows(displacement, hdf.nodes);
const auto reaction_rows = OrderedRows(reaction, hdf.nodes);
const auto section_rows = OrderedSectionRows(section, hdf);
+9
View File
@@ -84,6 +84,15 @@ class ReferenceComparison {
static Result<ComparisonReport> Compare(
const std::filesystem::path& results_hdf5,
const std::filesystem::path& legacy_reference_directory);
/// @brief Compares results using a separately copied execution input path.
/// @param execution_input_path Input path recorded by the solver in HDF5.
/// @note Reference inputs and CSV files remain in legacy_reference_directory.
static Result<ComparisonReport> Compare(
const std::filesystem::path& results_hdf5,
const std::filesystem::path& legacy_reference_directory,
const std::filesystem::path& execution_input_path);
static Status WriteDeterministicJson(
const ComparisonReport& report, const std::filesystem::path& output_json);
};
+125
View File
@@ -6,6 +6,17 @@ import pytest
from scripts import execute
def write_implementation_agent_profile(
root, *, model="gpt-5.6-terra", reasoning="xhigh"
):
profile = root / ".codex" / "agents" / "implementation-agent.toml"
profile.parent.mkdir(parents=True)
profile.write_text(
f'model = "{model}"\nmodel_reasoning_effort = "{reasoning}"\n',
encoding="utf-8",
)
def test_invoke_codex_uses_utf8_for_unicode_prompt(tmp_path, monkeypatch):
phase_dir = tmp_path / "phases" / "unicode-phase"
phase_dir.mkdir(parents=True)
@@ -20,6 +31,7 @@ def test_invoke_codex_uses_utf8_for_unicode_prompt(tmp_path, monkeypatch):
encoding="utf-8",
)
(phase_dir / "step0.md").write_text("Euler–Bernoulli 보", encoding="utf-8")
write_implementation_agent_profile(tmp_path)
invocation = {}
@@ -40,6 +52,44 @@ def test_invoke_codex_uses_utf8_for_unicode_prompt(tmp_path, monkeypatch):
assert invocation["command"][sandbox_index + 1] == "workspace-write"
def test_invoke_codex_uses_implementation_agent_command_contract(
tmp_path, monkeypatch
):
phase_dir = tmp_path / "phases" / "agent-phase"
phase_dir.mkdir(parents=True)
(phase_dir / "index.json").write_text(
json.dumps(
{
"project": "FESA Structural Solver",
"phase": "agent-phase",
"steps": [{"step": 0, "name": "agent", "status": "pending"}],
}
),
encoding="utf-8",
)
(phase_dir / "step0.md").write_text("agent routing", encoding="utf-8")
write_implementation_agent_profile(tmp_path)
invocation = {}
def capture_run(command, **kwargs):
invocation["command"] = command
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
monkeypatch.setattr(execute, "ROOT", tmp_path)
monkeypatch.setattr(execute.subprocess, "run", capture_run)
executor = execute.StepExecutor("agent-phase")
executor._invoke_codex({"step": 0, "name": "agent"}, "")
model_index = invocation["command"].index("--model")
config_index = invocation["command"].index("--config")
assert invocation["command"][model_index + 1] == "gpt-5.6-terra"
assert invocation["command"][config_index + 1] == 'model_reasoning_effort="xhigh"'
adjacent_arguments = list(zip(invocation["command"], invocation["command"][1:]))
assert ("--disable", "unified_exec") in adjacent_arguments
def test_invoke_codex_accepts_explicit_danger_full_access_override(
tmp_path, monkeypatch
):
@@ -56,6 +106,7 @@ def test_invoke_codex_accepts_explicit_danger_full_access_override(
encoding="utf-8",
)
(phase_dir / "step0.md").write_text("sandbox override", encoding="utf-8")
write_implementation_agent_profile(tmp_path)
invocation = {}
@@ -74,6 +125,80 @@ def test_invoke_codex_accepts_explicit_danger_full_access_override(
assert invocation["command"][sandbox_index + 1] == "danger-full-access"
def test_invoke_codex_timeout_writes_structured_failure(tmp_path, monkeypatch):
phase_dir = tmp_path / "phases" / "timeout-phase"
phase_dir.mkdir(parents=True)
(phase_dir / "index.json").write_text(
json.dumps(
{
"project": "FESA Structural Solver",
"phase": "timeout-phase",
"steps": [{"step": 0, "name": "timeout", "status": "pending"}],
}
),
encoding="utf-8",
)
(phase_dir / "step0.md").write_text("timeout", encoding="utf-8")
write_implementation_agent_profile(tmp_path)
def timeout_run(command, **kwargs):
raise subprocess.TimeoutExpired(
command, kwargs["timeout"], output="partial stdout", stderr="partial stderr"
)
monkeypatch.setattr(execute, "ROOT", tmp_path)
monkeypatch.setattr(execute.subprocess, "run", timeout_run)
executor = execute.StepExecutor("timeout-phase")
output = executor._invoke_codex({"step": 0, "name": "timeout"}, "")
assert output["exitCode"] == 124
assert output["stdout"] == "partial stdout"
assert "Codex timed out after 1800 seconds." in output["stderr"]
stored = json.loads((phase_dir / "step0-output.json").read_text(encoding="utf-8"))
assert stored == output
def test_execute_single_step_retries_structured_codex_timeout(tmp_path, monkeypatch):
phase_dir = tmp_path / "phases" / "retry-phase"
phase_dir.mkdir(parents=True)
(phase_dir / "index.json").write_text(
json.dumps(
{
"project": "FESA Structural Solver",
"phase": "retry-phase",
"steps": [{"step": 0, "name": "retry", "status": "pending"}],
}
),
encoding="utf-8",
)
(phase_dir / "step0.md").write_text("retry", encoding="utf-8")
write_implementation_agent_profile(tmp_path)
attempts = []
def timeout_run(command, **kwargs):
attempts.append(command)
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
monkeypatch.setattr(execute, "ROOT", tmp_path)
monkeypatch.setattr(execute.subprocess, "run", timeout_run)
executor = execute.StepExecutor("retry-phase")
executor.MAX_RETRIES = 2
monkeypatch.setattr(executor, "_commit_step", lambda *args: None)
monkeypatch.setattr(executor, "_update_top_index", lambda *args: None)
with pytest.raises(SystemExit, match="1"):
executor._execute_single_step(
{"step": 0, "name": "retry", "status": "pending"}, ""
)
assert len(attempts) == 2
index = json.loads((phase_dir / "index.json").read_text(encoding="utf-8"))
assert index["steps"][0]["status"] == "error"
assert "Codex timed out after 1800 seconds." in index["steps"][0]["error_message"]
def test_invoke_codex_rejects_unknown_sandbox_override(tmp_path, monkeypatch):
phase_dir = tmp_path / "phases" / "sandbox-phase"
phase_dir.mkdir(parents=True)
+141
View File
@@ -0,0 +1,141 @@
#include "fesa/app/output_paths.h"
#include <chrono>
#include <filesystem>
#include <stdexcept>
#include <string>
#include <vector>
#include <gtest/gtest.h>
#include "fesa/analysis/analysis.h"
namespace {
class TempDirectory {
public:
explicit TempDirectory(const std::string& label) {
const auto tick =
std::chrono::steady_clock::now().time_since_epoch().count();
path_ = std::filesystem::temp_directory_path() /
("fesa-output-paths-" + label + "-" + std::to_string(tick));
std::error_code error;
if (!std::filesystem::create_directory(path_, error) || error) {
throw std::runtime_error{"Unable to create an output-path test fixture."};
}
}
TempDirectory(const TempDirectory&) = delete;
TempDirectory& operator=(const TempDirectory&) = delete;
~TempDirectory() {
std::error_code ignored;
std::filesystem::remove_all(path_, ignored);
}
const std::filesystem::path& Path() const noexcept { return path_; }
private:
std::filesystem::path path_;
};
void ExpectFailureCode(const fesa::Status& status, const char* code) {
ASSERT_FALSE(status.IsOk());
ASSERT_EQ(status.Diagnostics().size(), 1U);
EXPECT_EQ(status.Diagnostics().front().code, code);
}
// NSNR-CLI-001 and NSNR-CLI-002: a token containing the literal substring
// must fail before input path validation or output derivation.
TEST(OutputPaths, RejectsFirstTokenContainingOutputSubstring) {
const auto paths = fesa::DeriveSingleInputOutputPaths(
{"abc--outputdef", "model.inp", "--output=ignored"},
std::filesystem::path{"C:/invocation"});
ASSERT_FALSE(paths.HasValue());
ExpectFailureCode(paths.GetStatus(), "cli-usage");
EXPECT_EQ(paths.GetStatus().Diagnostics().front().entity_identity,
"argument_index=0;argument=abc--outputdef");
}
TEST(OutputPaths, RejectsEveryOutputSubstringVariant) {
for (const char* argument :
{"abc--outputdef", "--output-path", "--output=foo", "--output"}) {
const auto paths = fesa::DeriveSingleInputOutputPaths(
{"model.inp", argument}, std::filesystem::path{"C:/invocation"});
ASSERT_FALSE(paths.HasValue()) << argument;
ExpectFailureCode(paths.GetStatus(), "cli-usage");
}
}
// NSNR-CLI-004: derivation is lexical and does not inspect the filesystem.
TEST(OutputPaths, DerivesNormalizedSiblingNamesFromRelativeInput) {
const auto paths = fesa::DeriveSingleInputOutputPaths(
{"inputs/../models/Beam.Model.INP"},
std::filesystem::path{"C:/invocation/current"});
ASSERT_TRUE(paths.HasValue()) << paths.GetStatus().Diagnostics().size();
EXPECT_EQ(
paths.Value().input_path,
std::filesystem::path{"C:/invocation/current/models/Beam.Model.INP"});
EXPECT_EQ(paths.Value().results_path,
std::filesystem::path{
"C:/invocation/current/models/Beam.Model_results.h5"});
EXPECT_EQ(paths.Value().log_path,
std::filesystem::path{
"C:/invocation/current/models/Beam.Model_log.txt"});
}
// NSNR-CLI-005: only the final, case-insensitive input extension is removed.
TEST(OutputPaths, PreservesBasenameCaseAndPrecedingDots) {
const auto paths = fesa::DeriveSingleInputOutputPaths(
{"C:/models/.Beam.v2.iNp"}, std::filesystem::path{"C:/unused"});
ASSERT_TRUE(paths.HasValue());
EXPECT_EQ(paths.Value().results_path,
std::filesystem::path{"C:/models/.Beam.v2_results.h5"});
EXPECT_EQ(paths.Value().log_path,
std::filesystem::path{"C:/models/.Beam.v2_log.txt"});
}
TEST(OutputPaths, RejectsInputWithoutInpFinalExtension) {
const auto paths = fesa::DeriveSingleInputOutputPaths(
{"model.inp.bak"}, std::filesystem::path{"C:/invocation"});
ASSERT_FALSE(paths.HasValue());
ExpectFailureCode(paths.GetStatus(), "cli-usage");
}
// NSNR-CLI-006: the lease is exclusive and does not create public outputs.
TEST(OutputPaths, LeaseCollisionPreservesPublicOutputPaths) {
TempDirectory directory{"lease"};
const fesa::DerivedOutputPaths paths{
directory.Path() / "model.inp", directory.Path() / "model_results.h5",
directory.Path() / "model_log.txt"};
auto first = fesa::AcquireOutputWriteLease(paths);
ASSERT_TRUE(first.HasValue()) << first.GetStatus().Diagnostics().size();
EXPECT_FALSE(std::filesystem::exists(paths.results_path));
EXPECT_FALSE(std::filesystem::exists(paths.log_path));
const auto second = fesa::AcquireOutputWriteLease(paths);
ASSERT_FALSE(second.HasValue());
ExpectFailureCode(second.GetStatus(), "output-busy");
EXPECT_FALSE(std::filesystem::exists(paths.results_path));
EXPECT_FALSE(std::filesystem::exists(paths.log_path));
}
// NSNR-CLI-009: procedure calls receive all derived output identities.
TEST(OutputPaths, AnalysisRequestCarriesInputResultAndLogPaths) {
const fesa::AnalysisRequest request{
std::filesystem::path{"C:/models/model.inp"},
std::filesystem::path{"C:/models/model_results.h5"},
std::filesystem::path{"C:/models/model_log.txt"}};
EXPECT_EQ(request.input_path, "C:/models/model.inp");
EXPECT_EQ(request.output_path, "C:/models/model_results.h5");
EXPECT_EQ(request.log_path, "C:/models/model_log.txt");
}
} // namespace