feat(nonlinear-static-newton-raphson): step 0 - cli-output-lease-and-derived-names
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user