-- Numerical and contract regression tests for the public LuaCoolProp API.
--
-- The assertions use physical invariants and cross-check two CoolProp entry
-- points instead of freezing version-specific reference tables.  This keeps
-- the suite useful when scripts/update-coolprop.sh selects another release.

local lcp = require("luacoolprop")

local function finite(value)
  return type(value) == "number"
    and value == value
    and value ~= math.huge
    and value ~= -math.huge
end

local function assert_close(actual, expected, relative_tolerance, message)
  assert(finite(actual), message .. ": actual value is not finite")
  assert(finite(expected), message .. ": expected value is not finite")
  local scale = math.max(1, math.abs(actual), math.abs(expected))
  local difference = math.abs(actual - expected)
  assert(difference <= relative_tolerance * scale,
    string.format("%s: relative difference %.6g exceeds %.6g",
      message, difference / scale, relative_tolerance))
end

local function assert_error_contains(callback, needle, message)
  local ok, result = pcall(callback)
  assert(not ok, message .. ": expected an error")
  assert(tostring(result):find(needle, 1, true),
    message .. ": unexpected diagnostic: " .. tostring(result))
end

-- Measure the same midpoint defect used by the TS/HS quality samplers, but in
-- the logarithmic reduced-pressure coordinate employed near the critical
-- point.  This independently verifies that tightening `tolerance` really
-- refines the critical tail rather than merely inserting a fixed point list.
local function critical_quality_midpoint_error(kind, fluid, quality, pcrit,
    first, second)
  local epsilon_first = 1 - first.p / pcrit
  local epsilon_second = 1 - second.p / pcrit
  if first.critical or second.critical
      or epsilon_first <= 0 or epsilon_second <= 0 then
    return 0
  end
  local epsilon_midpoint = math.sqrt(epsilon_first * epsilon_second)
  local pressure = pcrit * (1 - epsilon_midpoint)
  local entropy = lcp.propsSI("S", "P", pressure, "Q", quality, fluid)
  local ordinate
  if kind == "TS" then
    ordinate = lcp.propsSI("T", "P", pressure, "Q", quality, fluid)
  else
    ordinate = lcp.propsSI("H", "P", pressure, "Q", quality, fluid) * 1e-3
  end
  local dx = entropy * 1e-3 - 0.5 * (first.x + second.x)
  local dy = (ordinate - 0.5 * (first.y + second.y)) * 0.01
  return math.sqrt(dx * dx + dy * dy)
end

-- TeX key values can contain indentation around a braced path.  The loader
-- trims only this outer whitespace and preserves spaces inside directory
-- names.  Use a syntactically distinct `/./` path so an environment fallback
-- cannot make this regression pass accidentally.
local configured_library = os.getenv("LUACOOLPROP_LIB")
  or os.getenv("COOLPROP_LIB")
local explicit_library = configured_library
if explicit_library then
  local directory, basename = explicit_library:match("^(.*)/([^/]+)$")
  if directory and basename then
    explicit_library = directory .. "/./" .. basename
  else
    directory, basename = explicit_library:match("^(.*)\\([^\\]+)$")
    if directory and basename then
      explicit_library = directory .. "\\.\\" .. basename
    end
  end
end
local loaded, library_path = lcp.load_library(
  explicit_library and (" \n\t" .. explicit_library .. "  \r\n") or nil)
assert(loaded ~= nil, "CoolProp shared library must load")
assert(type(library_path) == "string" and library_path ~= "",
  "loaded library path must be reported")
if explicit_library then
  assert(library_path == explicit_library,
    "explicit library paths must ignore surrounding whitespace")
end
assert(type(lcp.version()) == "string" and lcp.version() ~= "",
  "CoolProp version must be non-empty")

local pressure = 2.5e5
local quality = 1
local direct_h = lcp.propsSI("Hmass", "P", pressure, "Q", quality, "R134a")
assert(finite(direct_h), "PropsSI enthalpy must be finite")

local state
local state_ok, state_result = xpcall(function()
  state = lcp.AbstractState("HEOS", "R134a")
  state:update("PQ_INPUTS", pressure, quality)
  return {
    h = state:keyed_output("Hmass"),
    p = state:keyed_output("P"),
  }
end, debug.traceback)
if state ~= nil then
  state:free()
  state:free()
  state = nil
end
if not state_ok then
  error(state_result, 0)
end
assert_close(state_result.h, direct_h, 2e-10,
  "PropsSI and AbstractState enthalpy")
assert_close(state_result.p, pressure, 2e-10,
  "AbstractState pressure after PQ update")

local constants = lcp.fluid_constants("R134a")
assert(finite(constants.pcrit) and constants.pcrit > 1e6,
  "critical pressure must be physically plausible")
assert(finite(constants.ptriple) and constants.ptriple > 0,
  "triple-point pressure must be positive")
assert(constants.pcrit > constants.ptriple,
  "critical pressure must exceed triple-point pressure")
assert(finite(constants.Tcrit) and finite(constants.Ttriple)
  and constants.Tcrit > constants.Ttriple,
  "critical temperature must exceed triple-point temperature")

local ph = lcp.diagram.get_type("PH")
assert(type(ph) == "table", "PH diagram registry entry must exist")
for _, family in ipairs({"quality", "isotherm", "isentrope", "isochore"}) do
  assert(type(ph.families[family]) == "table",
    family .. " family must be registered")
  assert(type(ph.families[family].curve) == "function",
    family .. " curve generator must exist")
  assert(type(ph.families[family].plots) == "function",
    family .. " plot serializer must exist")
end

local pv = lcp.diagram.get_type("PV")
assert(type(pv) == "table", "PV diagram registry entry must exist")
for _, family in ipairs({"quality", "isotherm", "isentrope"}) do
  assert(type(pv.families[family]) == "table",
    "PV " .. family .. " family must be registered")
  assert(type(pv.families[family].curve) == "function",
    "PV " .. family .. " curve generator must exist")
  assert(type(pv.families[family].plots) == "function",
    "PV " .. family .. " plot serializer must exist")
end

local ts = lcp.diagram.get_type("TS")
assert(type(ts) == "table", "TS diagram registry entry must exist")
for _, family in ipairs({"quality", "isenthalp"}) do
  assert(type(ts.families[family]) == "table",
    "TS " .. family .. " family must be registered")
  assert(type(ts.families[family].curve) == "function",
    "TS " .. family .. " curve generator must exist")
  assert(type(ts.families[family].plots) == "function",
    "TS " .. family .. " plot serializer must exist")
end

local hs = lcp.diagram.get_type("HS")
assert(type(hs) == "table", "HS diagram registry entry must exist")
for _, family in ipairs({"quality", "isochore", "isotherm", "isobar"}) do
  assert(type(hs.families[family]) == "table",
    "HS " .. family .. " family must be registered")
  assert(type(hs.families[family].curve) == "function",
    "HS " .. family .. " curve generator must exist")
  assert(type(hs.families[family].plots) == "function",
    "HS " .. family .. " plot serializer must exist")
end

local pt = lcp.diagram.get_type("PT")
assert(type(pt) == "table", "PT diagram registry entry must exist")
for _, family in ipairs({"phase_envelope", "isentrope", "isenthalp",
    "isochore"}) do
  assert(type(pt.families[family]) == "table",
    "PT " .. family .. " family must be registered")
  assert(type(pt.families[family].curve) == "function",
    "PT " .. family .. " curve generator must exist")
  assert(type(pt.families[family].plots) == "function",
    "PT " .. family .. " plot serializer must exist")
end

local curve_options = {
  fluid = "R134a",
  pressure_min = 1e5,
  pressure_max = 2e6,
  enthalpy_scale = 1e-3,
  pressure_scale = 1e-5,
}

local curve_cases = {
  {name = "quality", points = ph.quality_curve(curve_options, 0.5)},
  {name = "isotherm", points = ph.isotherm_curve(curve_options, 293.15)},
  {name = "isentrope", points = ph.isentrope_curve(curve_options, 1800)},
  {name = "isochore", points = ph.isochore_curve(curve_options, 0.05)},
}
for _, case in ipairs(curve_cases) do
  assert(#case.points >= 2, case.name .. " curve must contain at least two points")
  for index, point in ipairs(case.points) do
    assert(finite(point.x) and finite(point.y),
      string.format("%s point %d must have finite plot coordinates",
        case.name, index))
    assert(point.y > 0, case.name .. " pressure coordinate must be positive")
  end
end

local pv_curve_options = {
  fluid = "R134a",
  pressure_min = 1e5,
  pressure_max = 2e6,
  specific_volume_scale = 1,
  pressure_scale = 1e-5,
}
local pv_curve_cases = {
  {name = "quality", points = pv.quality_curve(pv_curve_options, 0.5)},
  {name = "isotherm", points = pv.isotherm_curve(pv_curve_options, 293.15)},
  {name = "isentrope", points = pv.isentrope_curve(pv_curve_options, 1800)},
}
for _, case in ipairs(pv_curve_cases) do
  assert(#case.points >= 2,
    "PV " .. case.name .. " curve must contain at least two points")
  for index, point in ipairs(case.points) do
    assert(finite(point.x) and point.x > 0
        and finite(point.y) and point.y > 0,
      string.format("PV %s point %d must be valid on log-log axes",
        case.name, index))
    assert_close(point.x, point.v, 2e-13,
      "PV x coordinate must equal SI specific volume at scale one")
    assert_close(point.y, point.p * 1e-5, 2e-13,
      "PV y coordinate must equal scaled pressure")
  end
end

local ts_curve_options = {
  fluid = "R134a",
  pressure_min = 1e5,
  pressure_max = 2e6,
  entropy_scale = 1e-3,
  temperature_scale = 1,
}
local ts_curve_cases = {
  {name = "quality", points = ts.quality_curve(ts_curve_options, 0.5)},
  {name = "isenthalp", points = ts.isenthalp_curve(ts_curve_options, 3e5)},
}
for _, case in ipairs(ts_curve_cases) do
  assert(#case.points >= 2,
    "TS " .. case.name .. " curve must contain at least two points")
  for index, point in ipairs(case.points) do
    assert(finite(point.x) and finite(point.y) and point.y > 0,
      string.format("TS %s point %d must have finite coordinates",
        case.name, index))
    assert_close(point.x, point.s * 1e-3, 2e-13,
      "TS x coordinate must equal scaled specific entropy")
    assert_close(point.y, point.T, 2e-13,
      "TS y coordinate must equal absolute temperature")
  end
end

local hs_curve_options = {
  fluid = "Water", pressure_min = 1e4, pressure_max = 3e7,
  entropy_scale = 1e-3, enthalpy_scale = 1e-3,
  temperature_unit = "kelvin",
  isobar_temperature_min = 280, isobar_temperature_max = 700,
}
local hs_curve_cases = {
  {name = "quality", points = hs.quality_curve(hs_curve_options, 0.5)},
  {name = "isochore", points = hs.isochore_curve(hs_curve_options, 0.1)},
  {name = "isotherm", points = hs.isotherm_curve(hs_curve_options, 473.15)},
  {name = "isobar", points = hs.isobar_curve(hs_curve_options, 1e5)},
}
for _, case in ipairs(hs_curve_cases) do
  assert(#case.points >= 2,
    "HS " .. case.name .. " curve must contain at least two points")
  for _, point in ipairs(case.points) do
    assert_close(point.x, point.s * 1e-3, 2e-13,
      "HS x coordinate must equal scaled entropy")
    assert_close(point.y, point.h * 1e-3, 2e-13,
      "HS y coordinate must equal scaled enthalpy")
  end
end

local pt_options = {
  fluid = "R134a",
  pressure_min = constants.ptriple,
  pressure_max = constants.pcrit * 1.1,
  temperature_axis_unit = "celsius",
  pressure_axis_unit = "bar",
}
local phase_envelope = pt.phase_envelope_curve(pt_options)
assert(#phase_envelope >= 3,
  "PT phase envelope must contain sampled points and exact endpoints")
assert(phase_envelope[1].triple and phase_envelope[#phase_envelope].critical,
  "PT phase envelope must expose exact triple and critical endpoints")
assert_close(phase_envelope[1].T, constants.Ttriple, 2e-12,
  "PT phase envelope triple-point temperature")
assert_close(phase_envelope[1].p, constants.ptriple, 2e-12,
  "PT phase envelope triple-point pressure")
assert_close(phase_envelope[#phase_envelope].T, constants.Tcrit, 2e-12,
  "PT phase envelope critical temperature")
assert_close(phase_envelope[#phase_envelope].p, constants.pcrit, 2e-12,
  "PT phase envelope critical pressure")
assert(phase_envelope[1].quality_defined == false
    and phase_envelope[#phase_envelope].quality_defined == false,
  "quality must remain undefined on the single PT coexistence locus")
for index = 2, #phase_envelope do
  assert(phase_envelope[index].p > phase_envelope[index - 1].p,
    "PT coexistence pressure must increase strictly from triple to critical")
  assert(phase_envelope[index].T >= phase_envelope[index - 1].T - 1e-9,
    "PT coexistence temperature must not decrease")
end

-- Endpoint and monotonicity invariants must follow the selected fluid rather
-- than an R134a-specific table.  These two fluids exercise markedly different
-- triple pressures and critical temperatures.
for _, fluid in ipairs({"Water", "Propane"}) do
  local fluid_constants = lcp.fluid_constants(fluid)
  local curve = pt.phase_envelope_curve({
    fluid = fluid,
    pressure_min = fluid_constants.ptriple,
    pressure_max = fluid_constants.pcrit * 1.02,
    initial_intervals = 6,
    max_depth = 4,
  })
  assert(#curve >= 3 and curve[1].triple and curve[#curve].critical,
    "PT coexistence endpoints must be available for " .. fluid)
  assert_close(curve[1].p, fluid_constants.ptriple, 2e-12,
    fluid .. " PT triple pressure")
  assert_close(curve[#curve].p, fluid_constants.pcrit, 2e-12,
    fluid .. " PT critical pressure")
  for index = 2, #curve do
    assert(curve[index].p > curve[index - 1].p,
      fluid .. " PT coexistence pressure ordering")
    assert(curve[index].T >= curve[index - 1].T - 1e-9,
      fluid .. " PT coexistence temperature ordering")
  end
end

local pt_cases = {
  {name = "isentrope", points = pt.isentrope_curve(pt_options, 1700)},
  {name = "isenthalp", points = pt.isenthalp_curve(pt_options, 3e5)},
  {name = "isochore", points = pt.isochore_curve(pt_options, 0.02)},
}
for _, case in ipairs(pt_cases) do
  assert(#case.points >= 2, "PT " .. case.name .. " must contain points")
  for _, point in ipairs(case.points) do
    assert_close(point.x, point.T - 273.15, 2e-12,
      "PT Celsius coordinate coupling")
    assert_close(point.y, point.p * 1e-5, 2e-12,
      "PT bar coordinate coupling")
  end
  assert(type(case.points.segments) == "table",
    "PT adaptive curves must expose connected-domain metadata")
end

local pt_process, pt_process_metadata = pt.process_points({
  fluid = "R134a", temperature_axis_unit = "celsius",
  pressure_axis_unit = "bar", type = "isentropic",
  from = "pressure=2bar,quality=1", to = "pressure=10bar",
})
assert(#pt_process >= 2 and pt_process_metadata.diagram_type == "PT",
  "PT process must be sampled through the shared process contract")
assert_close(pt_process_metadata.from.x,
  pt_process_metadata.from.T - 273.15, 2e-12,
  "PT process Celsius coordinate")

local plateau = pv.isotherm_curve({
  fluid = "R134a", pressure_min = 1e5, pressure_max = 5e6,
}, 293.15)
local has_two_phase_plateau = false
for index = 2, #plateau do
  if plateau[index - 1].q == 1 and plateau[index].q == 0
      and plateau[index - 1].p == plateau[index].p
      and plateau[index - 1].v > plateau[index].v then
    has_two_phase_plateau = true
  end
end
assert(has_two_phase_plateau,
  "subcritical PV isotherms must contain their exact two-phase plateau")

-- Saturated liquid and saturated vapour are distinct at the triple pressure,
-- but every quality curve must converge to one common state at the critical
-- point.  Exercise several fluid families so this invariant cannot regress to
-- a refrigerant-specific workaround.
for _, fluid in ipairs({
    "R134a", "R717", "Water", "CO2", "Propane", "Methane", "Nitrogen",
  }) do
  local fluid_constants = lcp.fluid_constants(fluid)
  local critical_options = {
    fluid = fluid,
    pressure_min = math.max(fluid_constants.ptriple * 1.05,
      fluid_constants.pcrit * 0.02),
    pressure_max = fluid_constants.pcrit * 1.05,
    enthalpy_scale = 1e-3,
    pressure_scale = 1e-5,
    initial_intervals = 6,
    max_depth = 5,
  }
  local q0 = ph.quality_curve(critical_options, 0)
  local qhalf = ph.quality_curve(critical_options, 0.5)
  local q1 = ph.quality_curve(critical_options, 1)
  assert(#q0 >= 5 and #qhalf >= 5 and #q1 >= 5,
    fluid .. " quality curves must be refined near the critical point")
  local ends = {q0[#q0], qhalf[#qhalf], q1[#q1]}
  for _, endpoint in ipairs(ends) do
    assert(endpoint.critical == true,
      fluid .. " quality curve must expose its canonical critical endpoint")
    assert(endpoint.critical_limit == true
        and endpoint.quality_defined == false and endpoint.q == nil,
      fluid .. " critical endpoint must be a quality-independent limit")
    assert_close(endpoint.p, fluid_constants.pcrit, 2e-13,
      fluid .. " quality endpoint pressure")
  end
  assert(ends[1].x == ends[2].x and ends[2].x == ends[3].x
      and ends[1].y == ends[2].y and ends[2].y == ends[3].y,
    fluid .. " quality curves must have one bit-identical critical endpoint")
  for _, points in ipairs({q0, qhalf, q1}) do
    local penultimate = points[#points - 1]
    assert(penultimate.p < fluid_constants.pcrit
        and penultimate.p > 0.989 * fluid_constants.pcrit,
      fluid .. " quality sampling must approach the critical point from below")
  end

  local pv_q0 = pv.quality_curve(critical_options, 0)
  local pv_qhalf = pv.quality_curve(critical_options, 0.5)
  local pv_q1 = pv.quality_curve(critical_options, 1)
  local pv_ends = {pv_q0[#pv_q0], pv_qhalf[#pv_qhalf], pv_q1[#pv_q1]}
  assert(pv_ends[1].critical and pv_ends[2].critical
      and pv_ends[3].critical,
    fluid .. " PV qualities must reach the canonical critical state")
  for _, endpoint in ipairs(pv_ends) do
    assert(endpoint.critical_limit == true
        and endpoint.quality_defined == false and endpoint.q == nil,
      fluid .. " PV critical endpoint must not carry a quality")
  end
  assert(pv_ends[1].x == pv_ends[2].x
      and pv_ends[2].x == pv_ends[3].x
      and pv_ends[1].y == pv_ends[2].y
      and pv_ends[2].y == pv_ends[3].y,
    fluid .. " PV quality curves must share one critical endpoint")
  assert_close(pv_ends[1].v, 1 / fluid_constants.rhocrit, 2e-13,
    fluid .. " PV critical volume")

  local ts_q0 = ts.quality_curve(critical_options, 0)
  local ts_qhalf = ts.quality_curve(critical_options, 0.5)
  local ts_q1 = ts.quality_curve(critical_options, 1)
  local ts_ends = {ts_q0[#ts_q0], ts_qhalf[#ts_qhalf], ts_q1[#ts_q1]}
  assert(ts_ends[1].critical and ts_ends[2].critical
      and ts_ends[3].critical,
    fluid .. " TS qualities must reach the canonical critical state")
  for _, endpoint in ipairs(ts_ends) do
    assert(endpoint.critical_limit == true
        and endpoint.quality_defined == false and endpoint.q == nil,
      fluid .. " TS critical endpoint must not carry a quality")
  end
  assert(ts_ends[1].x == ts_ends[2].x
      and ts_ends[2].x == ts_ends[3].x
      and ts_ends[1].y == ts_ends[2].y
      and ts_ends[2].y == ts_ends[3].y,
    fluid .. " TS quality curves must share one critical endpoint")
  assert_close(ts_ends[1].T, fluid_constants.Tcrit, 2e-13,
    fluid .. " TS critical temperature")

  local hs_q0 = hs.quality_curve(critical_options, 0)
  local hs_qhalf = hs.quality_curve(critical_options, 0.5)
  local hs_q1 = hs.quality_curve(critical_options, 1)
  local hs_ends = {hs_q0[#hs_q0], hs_qhalf[#hs_qhalf], hs_q1[#hs_q1]}
  assert(hs_ends[1].critical and hs_ends[2].critical
      and hs_ends[3].critical,
    fluid .. " HS qualities must reach the canonical critical state")
  for _, endpoint in ipairs(hs_ends) do
    assert(endpoint.critical_limit == true
        and endpoint.quality_defined == false and endpoint.q == nil,
      fluid .. " HS critical endpoint must not carry a quality")
  end
  assert(hs_ends[1].x == hs_ends[2].x
      and hs_ends[2].x == hs_ends[3].x
      and hs_ends[1].y == hs_ends[2].y
      and hs_ends[2].y == hs_ends[3].y,
    fluid .. " HS quality curves must share one critical endpoint")

  local strict_tolerance = 0.002
  local strict_options = {
    fluid = fluid,
    pressure_min = critical_options.pressure_min,
    pressure_max = critical_options.pressure_max,
    entropy_scale = 1e-3,
    enthalpy_scale = 1e-3,
    initial_intervals = 4,
    max_depth = 12,
    tolerance = strict_tolerance,
  }
  for _, quality in ipairs({0, 0.5, 1}) do
    for _, case in ipairs({
        {name = "TS", points = ts.quality_curve(strict_options, quality)},
        {name = "HS", points = hs.quality_curve(strict_options, quality)},
      }) do
      local checked = 0
      for index = 1, #case.points - 1 do
        local first, second = case.points[index], case.points[index + 1]
        local epsilon_first = 1 - first.p / fluid_constants.pcrit
        if epsilon_first > 0 and epsilon_first <= 0.5
            and not second.critical then
          local defect = critical_quality_midpoint_error(case.name, fluid,
            quality, fluid_constants.pcrit, first, second)
          assert(defect <= strict_tolerance * (1 + 1e-8),
            string.format(
              "%s %s Q=%.1f critical-tail defect %.6g exceeds %.6g",
              fluid, case.name, quality, defect, strict_tolerance))
          checked = checked + 1
        end
      end
      assert(checked > 0,
        fluid .. " " .. case.name
          .. " quality curve must exercise adaptive critical-tail segments")
    end
  end
end

-- CoolProp's built-in fluid list changes between releases.  Audit it
-- dynamically so updating the external library also tests every newly added
-- fluid without hard-coding package data in LuaCoolProp.
local audited_fluid_count = 0
for fluid in lcp.global_param_string("FluidsList"):gmatch("[^,]+") do
  if lcp.fluid_param_string(fluid, "pure"):lower() == "true" then
    local fluid_constants = lcp.fluid_constants(fluid)
  assert(finite(fluid_constants.pcrit) and finite(fluid_constants.ptriple)
      and fluid_constants.pcrit > fluid_constants.ptriple,
    fluid .. " must expose an ordered triple-to-critical pressure interval")
  local audit_options = {
    fluid = fluid,
    pressure_min = math.max(fluid_constants.ptriple * 1.05,
      fluid_constants.pcrit * 0.02),
    pressure_max = fluid_constants.pcrit * 1.01,
    initial_intervals = 2,
    max_depth = 1,
  }
  local liquid = ph.quality_curve(audit_options, 0)
  local vapour = ph.quality_curve(audit_options, 1)
  local liquid_end, vapour_end = liquid[#liquid], vapour[#vapour]
  assert(liquid_end and vapour_end
      and liquid_end.critical == true and vapour_end.critical == true
      and liquid_end.p == vapour_end.p
      and liquid_end.x == vapour_end.x and liquid_end.y == vapour_end.y,
    fluid .. " saturation branches must share one critical endpoint")
  local pv_liquid = pv.quality_curve(audit_options, 0)
  local pv_vapour = pv.quality_curve(audit_options, 1)
  local pv_liquid_end = pv_liquid[#pv_liquid]
  local pv_vapour_end = pv_vapour[#pv_vapour]
  assert(pv_liquid_end and pv_vapour_end
      and pv_liquid_end.critical == true
      and pv_vapour_end.critical == true
      and pv_liquid_end.x == pv_vapour_end.x
      and pv_liquid_end.y == pv_vapour_end.y,
    fluid .. " PV saturation branches must share one critical endpoint")
  local ts_liquid = ts.quality_curve(audit_options, 0)
  local ts_vapour = ts.quality_curve(audit_options, 1)
  local ts_liquid_end = ts_liquid[#ts_liquid]
  local ts_vapour_end = ts_vapour[#ts_vapour]
  assert(ts_liquid_end and ts_vapour_end
      and ts_liquid_end.critical == true
      and ts_vapour_end.critical == true
      and ts_liquid_end.x == ts_vapour_end.x
      and ts_liquid_end.y == ts_vapour_end.y,
    fluid .. " TS saturation branches must share one critical endpoint")
  local hs_liquid = hs.quality_curve(audit_options, 0)
  local hs_vapour = hs.quality_curve(audit_options, 1)
  local hs_liquid_end = hs_liquid[#hs_liquid]
  local hs_vapour_end = hs_vapour[#hs_vapour]
  assert(hs_liquid_end and hs_vapour_end
      and hs_liquid_end.critical and hs_vapour_end.critical
      and hs_liquid_end.x == hs_vapour_end.x
      and hs_liquid_end.y == hs_vapour_end.y,
    fluid .. " HS saturation branches must share one critical endpoint")
    audited_fluid_count = audited_fluid_count + 1
  end
end
assert(audited_fluid_count > 0,
  "CoolProp must report at least one built-in fluid for the closure audit")

local truncated_quality = ph.quality_curve({
  fluid = "R134a",
  pressure_min = constants.ptriple * 1.05,
  pressure_max = constants.pcrit * 0.8,
}, 0)
assert(#truncated_quality >= 2
    and truncated_quality[#truncated_quality].critical ~= true,
  "a quality curve requested below pc must not invent a critical endpoint")
assert_close(truncated_quality[#truncated_quality].p,
  constants.pcrit * 0.8, 2e-13,
  "a truncated quality curve must preserve its requested upper pressure")

local plot_code = ph.plots({
  fluid = "R134a",
  quality = true,
  isotherm = true,
  quality_values = "0,0.5,1",
  temperature_values = "-20,0,20",
  temperature_unit = "celsius",
  labels = true,
})
assert(plot_code:find("\\addplot", 1, true),
  "PH serializer must emit PGFPlots plots")
assert(plot_code:find("name path=lcp-ph-q-0p5", 1, true),
  "PH serializer must emit stable quality-curve names")
assert(plot_code:find("\\pgfplotsautonode", 1, true),
  "labelled PH serialization must delegate to pgfplots-autonode")
assert(plot_code:find("\\csname LCP@format@number\\endcsname", 1, true),
  "dimensionless generated labels must use the TeX number formatter")
assert(plot_code:find("\\csname LCP@format@quantity\\endcsname", 1, true)
    and plot_code:find("\\degreeCelsius", 1, true),
  "unit-bearing generated labels must use the TeX quantity formatter")

local symbol_code = ph.plots({
  fluid = "R134a",
  quality = true,
  isotherm = true,
  isentrope = true,
  isochore = true,
  quality_values = "0.5",
  temperature_values = "20",
  entropy_values = "1.8",
  specific_volume_values = "0.05",
  quality_symbol = "x",
  temperature_symbol = "\\theta",
  entropy_symbol = "\\sigma",
  specific_volume_symbol = "\\nu",
  labels = true,
  label_every = 1,
})
for _, labelled_symbol in ipairs({"x", "\\theta", "\\sigma", "\\nu"}) do
  assert(symbol_code:find("$" .. labelled_symbol .. "=", 1, true),
    labelled_symbol .. " must replace the corresponding generated symbol")
end

local pv_plot_code = pv.plots({
  fluid = "R134a",
  quality = true,
  isotherm = true,
  isentrope = true,
  quality_values = "0,0.5,1",
  temperature_values = "0,20",
  entropy_values = "1.7,1.9",
  quality_symbol = "x",
  temperature_symbol = "\\theta",
  entropy_symbol = "\\sigma",
  labels = true,
  label_every = 1,
})
assert(pv_plot_code:find("name path=lcp-pv-q-0p5", 1, true),
  "PV serializer must emit diagram-specific stable path names")
for _, labelled_symbol in ipairs({"x", "\\theta", "\\sigma"}) do
  assert(pv_plot_code:find("$" .. labelled_symbol .. "=", 1, true),
    "PV labels must honour the configured " .. labelled_symbol .. " symbol")
end

local ts_plot_code = ts.plots({
  fluid = "R134a",
  quality = true,
  isenthalp = true,
  quality_values = "0,0.5,1",
  enthalpy_values = "200,300,400",
  enthalpy_unit = "kjkg",
  quality_symbol = "x",
  enthalpy_symbol = "\\eta",
  labels = true,
  label_every = 1,
})
assert(ts_plot_code:find("name path=lcp-ts-q-0p5", 1, true),
  "TS serializer must emit diagram-specific stable path names")
assert(ts_plot_code:find("name path=lcp-ts-h-300", 1, true),
  "TS serializer must emit stable isenthalp path names")
for _, labelled_symbol in ipairs({"x", "\\eta"}) do
  assert(ts_plot_code:find("$" .. labelled_symbol .. "=", 1, true),
    "TS labels must honour the configured " .. labelled_symbol .. " symbol")
end

local hs_plot_code = hs.plots({
  fluid = "Water", pressure_min = 1e4, pressure_max = 3e7,
  quality = true, isochore = true, isotherm = true, isobar = true,
  quality_values = "0,0.5,1", specific_volume_values = "0.01,0.1",
  temperature_values = "100,200,300", isobar_values = "1,10,100",
  labels = true, label_every = 1,
})
for _, name in ipairs({"lcp-hs-q-0p5", "lcp-hs-v-", "lcp-hs-T-",
    "lcp-hs-p-"}) do
  assert(hs_plot_code:find("name path=" .. name, 1, true),
    "HS serializer must emit stable " .. name .. " paths")
end

local process_points, process_metadata = ph.process_points({
  fluid = "R134a",
  type = "isentrope",
  from = "pressure=2bar,quality=1",
  to = "pressure=10bar",
})
assert(#process_points >= 2, "isentrope process must contain points")
assert(process_metadata.kind == "isentropic",
  "process vocabulary must normalize to the thermodynamic implementation")
assert_close(process_metadata.from.s, process_metadata.to.s, 2e-8,
  "isentrope endpoints must conserve entropy")
for _, endpoint in ipairs({process_metadata.from, process_metadata.to}) do
  assert(finite(endpoint.x) and finite(endpoint.y),
    "completed process endpoints must expose finite plot coordinates")
end

local process_code, rendered_process_metadata = ph.process_plot({
  fluid = "R134a",
  type = "isobar",
  pressure = "8bar",
  from = "quality=0",
  to = "quality=1",
  name = "test-process-path",
  coord_digits = 7,
})
assert(rendered_process_metadata.id == "test-process-path",
  "a process name must become the metadata path ID")
assert(process_code:find("name path=test-process-path", 1, true),
  "a named process must expose a TikZ path for intersections")
assert(process_code:find("(" .. tostring(rendered_process_metadata.from.x):sub(1, 5),
    1, true),
  "process serialization must contain its numeric endpoint")

local _, throttle_metadata = ph.process_points({
  fluid = "R134a",
  type = "isenthalp",
  from = "pressure=18bar,temperature=60C",
  to = "pressure=3bar",
})
assert(finite(throttle_metadata.to.q)
    and throttle_metadata.to.q > 0 and throttle_metadata.to.q < 1,
  "a two-phase process endpoint must expose its resolved quality")
assert(finite(throttle_metadata.to.s) and finite(throttle_metadata.to.v),
  "a resolved two-phase endpoint must expose entropy and specific volume")

local pv_process_points, pv_process_metadata = pv.process_points({
  fluid = "R134a",
  type = "isentropic",
  from = "pressure=2bar,quality=1",
  to = "pressure=12bar",
})
assert(#pv_process_points >= 2,
  "PV isentropic process must contain sampled points")
assert(pv_process_metadata.id:find("lcp%-pv%-process"),
  "PV processes must use diagram-specific path identifiers")
assert_close(pv_process_metadata.from.s, pv_process_metadata.to.s, 2e-8,
  "PV process endpoints must conserve entropy")
for _, endpoint in ipairs({pv_process_metadata.from, pv_process_metadata.to}) do
  assert_close(endpoint.x, endpoint.v, 2e-13,
    "PV process endpoint x must be its specific volume")
  assert(endpoint.x > 0 and endpoint.y > 0,
    "PV process endpoints must be valid on log-log axes")
end

local pv_process_code = pv.process_plot({
  fluid = "R134a", type = "isobar", pressure = "8bar",
  from = "quality=0", to = "quality=1", name = "test-pv-process",
})
assert(pv_process_code:find("name path=test-pv-process", 1, true),
  "named PV processes must expose TikZ paths for intersections")

local ts_process_points, ts_process_metadata = ts.process_points({
  fluid = "R134a",
  type = "isentropic",
  from = "pressure=2bar,quality=1",
  to = "pressure=12bar",
})
assert(#ts_process_points == 2,
  "a TS isentrope must be represented by its exact straight endpoints")
assert_close(ts_process_metadata.from.s, ts_process_metadata.to.s, 2e-8,
  "TS isentrope endpoints must conserve entropy")
assert_close(ts_process_metadata.from.x, ts_process_metadata.to.x, 2e-8,
  "TS isentrope endpoints must share one horizontal coordinate")
for _, endpoint in ipairs({ts_process_metadata.from, ts_process_metadata.to}) do
  assert_close(endpoint.x, endpoint.s * 1e-3, 2e-13,
    "TS process endpoint x must be scaled entropy")
  assert_close(endpoint.y, endpoint.T, 2e-13,
    "TS process endpoint y must be absolute temperature")
end

local ts_isobar = ts.process_points({
  fluid = "R134a", type = "isobar", pressure = "8bar",
  from = "quality=0", to = "quality=1",
})
assert(#ts_isobar >= 2,
  "a TS two-phase isobar must contain its entropy-parametrized plateau")
for _, point in ipairs(ts_isobar) do
  assert_close(point.T, ts_isobar[1].T, 2e-8,
    "TS two-phase isobar temperature")
end

local hs_process, hs_metadata = hs.process_points({
  fluid = "Water", type = "isentropic",
  from = "pressure=1bar,quality=1", to = "pressure=20bar",
})
assert(#hs_process == 2,
  "an HS isentrope must use its exact vertical endpoints")
assert_close(hs_metadata.from.x, hs_metadata.to.x, 2e-8,
  "HS isentrope plot coordinate")
for _, endpoint in ipairs({hs_metadata.from, hs_metadata.to}) do
  assert_close(endpoint.x, endpoint.s * 1e-3, 2e-13,
    "HS process x coordinate")
  assert_close(endpoint.y, endpoint.h * 1e-3, 2e-13,
    "HS process y coordinate")
end

-- A rigid two-phase tank is a demanding isochore case: its initial T-Q pair
-- determines v, while the target gives T only.  Completing pressure first must
-- not replace the valid target T-rho pair with the ambiguous saturation P-T
-- pair before enthalpy, entropy, and quality are evaluated.
local butane_isochore, butane_isochore_metadata = pv.process_points({
  fluid = "Butane", type = "isochore",
  from = "temperature=305K,quality=0.3",
  to = "temperature=335K",
})
assert(#butane_isochore == 2,
  "an isochore must be emitted as one exact vertical segment")
assert_close(butane_isochore_metadata.from.v,
  butane_isochore_metadata.to.v, 2e-13,
  "PV isochore endpoints must retain one specific volume")
assert_close(butane_isochore_metadata.from.q, 0.3, 2e-11,
  "butane isochore initial quality")
assert_close(butane_isochore_metadata.to.q, 0.665250479, 2e-8,
  "butane isochore target quality resolved from T and v")
assert(butane_isochore_metadata.to.p > butane_isochore_metadata.from.p,
  "heating the rigid two-phase butane tank must raise its pressure")

-- TeX-facing quantities use an intentionally strict, locale-independent
-- grammar.  Decimal floating-point and E notation are accepted, while a
-- decimal comma, a partial token, or an unknown unit is always an error.
local _, scientific_pressure = ph.process_points({
  fluid = "R134a", type = "isobar", pressure = "1.2E5Pa",
  from = "quality=0", to = "quality=1",
})
assert_close(scientific_pressure.constant, 1.2e5, 1e-13,
  "scientific-notation pressure with an attached SI unit")
assert_close(scientific_pressure.from.p, 1.2e5, 1e-12,
  "scientific-notation pressure propagated to a process endpoint")

local _, decimal_quality = ph.process_points({
  fluid = "R134a", type = "isoquality", quality = "5.0E-1",
  from = "pressure=1.2E5Pa", to = "pressure=8.0E5Pa",
})
assert_close(decimal_quality.constant, 0.5, 1e-13,
  "floating-point quality in scientific notation")

local decimal_bound_curve = ph.quality_curve({
  fluid = "R134a", pressure_min = "1.0E5", pressure_max = "2.0E6",
}, 0.5)
assert(#decimal_bound_curve >= 2,
  "diagram APIs must accept strict decimal scientific bounds")

assert_error_contains(function()
  ph.quality_curve({
    fluid = "R134a", pressure_min = "0x186A0", pressure_max = "2E6",
  }, 0.5)
end, "full finite decimal number", "hexadecimal Lua numeric spelling")

assert_error_contains(function()
  ph.process_points({
    fluid = "R134a", type = "isobar", pressure = "1,2bar",
    from = "quality=0", to = "quality=1",
  })
end, "use '.' as the decimal separator", "decimal-comma quantity")

assert_error_contains(function()
  ph.process_points({
    fluid = "R134a", type = "isobar", pressure = "1.2E5Paa",
    from = "quality=0", to = "quality=1",
  })
end, "unknown pressure unit", "unknown quantity unit")

assert_error_contains(function()
  ph.axis_style({
    fluid = "R134a", pressure_min = "not-a-number",
    pressure_max = 2e6,
  })
end, "invalid numeric value", "malformed numeric key")

assert_error_contains(function()
  ph.axis_style({fluid = "R134a", pressure_min = 2e6,
    pressure_max = 1e6})
end, "pressure_max must be greater", "reversed pressure bounds")

assert_error_contains(function()
  ph.plots({
    fluid = "R134a", pressure_min = 1e5, pressure_max = 2e5,
    isoquality = "treu",
  })
end, "invalid boolean value", "malformed boolean key")

assert_error_contains(function()
  ph.plots({
    fluid = "R134a", pressure_min = 1e5, pressure_max = 2e5,
    quality_values = "0,broken,1",
  })
end, "invalid numeric list element", "malformed numeric-list member")

assert_error_contains(function()
  ph.plots({
    fluid = "R134a", pressure_min = 1e5, pressure_max = 2e5,
    quality_mode = "linar",
  })
end, "invalid grid mode", "unknown grid mode")

assert_error_contains(function()
  ph.plots({
    fluid = "R134a", pressure_min = 1e5, pressure_max = 2e5,
    quality_preset = "dens",
  })
end, "invalid quality preset", "unknown grid preset")

-- A redundant property must describe the state selected by the defining
-- CoolProp pair; it is never retained merely because the user supplied it.
assert_error_contains(function()
  ph.process_points({
    fluid = "R134a", type = "isobar",
    from = "pressure=1bar,quality=0,temperature=500K",
    to = "pressure=1bar,quality=1",
  })
end, "inconsistent temperature T", "overdetermined contradictory state")

-- Likewise, both endpoints must satisfy the declared conservation law even
-- when each endpoint is a valid thermodynamic state on its own.
assert_error_contains(function()
  ph.process_points({
    fluid = "R134a", type = "isobar",
    from = "pressure=1bar,quality=0",
    to = "pressure=2bar,quality=1",
  })
end, "inconsistent pressure p", "contradictory process endpoints")

local saturation_temperature = lcp.propsSI(
  "T", "P", 1e5, "Q", 0, "R134a")
local _, consistent_redundancy = ph.process_points({
  fluid = "R134a", type = "isobar", pressure = "1E5Pa",
  from = string.format("p=1E5Pa,Q=0,T=%.12gK", saturation_temperature),
  to = "p=1E5Pa,Q=1",
})
assert_close(consistent_redundancy.from.T, saturation_temperature, 5e-7,
  "consistent redundant state property")

local unknown_ok, unknown_error = pcall(lcp.diagram.get_type, "UNKNOWN")
assert(not unknown_ok and tostring(unknown_error):find("unknown diagram type", 1, true),
  "unknown diagram types must fail with a useful error")

local dummy = {plots = function() return "dummy" end}
lcp.diagram.register_type("TEST", dummy)
assert(lcp.diagram.get_type("TEST") == dummy,
  "diagram registration must preserve uppercase identifiers")

-- Background families are capabilities, not keys inherited from whichever
-- diagram happened to be implemented first.
assert_error_contains(function()
  pv.plots({fluid = "R134a", pressure_min = 1e5, pressure_max = 2e5,
    isochore = true})
end, "does not provide the 'isochore'", "unsupported PV family")
assert_error_contains(function()
  ts.plots({fluid = "R134a", pressure_min = 1e5, pressure_max = 2e5,
    isotherm = true})
end, "does not provide the 'isotherm'", "unsupported TS family")
assert_error_contains(function()
  hs.plots({fluid = "R134a", pressure_min = 1e5, pressure_max = 2e5,
    isentrope = true})
end, "does not provide the 'isentrope'", "unsupported HS family")
assert_error_contains(function()
  ph.plots({fluid = "R134a", pressure_min = 1e5, pressure_max = 2e5,
    isenthalp = true})
end, "does not provide the 'isenthalp'", "unsupported PH family")
assert_error_contains(function()
  pt.plots({fluid = "R134a", quality = true})
end, "does not provide the 'quality'", "unsupported PT quality family")
assert_error_contains(function()
  pv.plots({fluid = "R134a", pressure_min = 1e5, pressure_max = 2e5,
    enthalpy_axis_unit = "kjkg"})
end, "is not an axis option of diagram type PV",
  "coordinate-unit capability mismatch")

-- A semantic axis unit changes the coordinate and its advertised unit as one
-- operation.  Raw SI fields remain invariant.
local unit_base = {fluid = "R134a", pressure_min = 1e5,
  pressure_max = 2e5, initial_intervals = 2, max_depth = 1}
local ph_named_default = ph.quality_curve(unit_base, 0.5)[1]
local ph_named_si = ph.quality_curve({fluid = "R134a", pressure_min = 1e5,
  pressure_max = 2e5, initial_intervals = 2, max_depth = 1,
  enthalpy_axis_unit = "jkg", pressure_axis_unit = "mpa"}, 0.5)[1]
assert_close(ph_named_si.h, ph_named_default.h, 1e-13,
  "semantic enthalpy unit preserves SI data")
assert_close(ph_named_si.x, ph_named_default.x * 1000, 1e-13,
  "J/kg coordinate scaling")
assert_close(ph_named_si.y, ph_named_default.y * 0.1, 1e-13,
  "MPa coordinate scaling")
local ts_kelvin = ts.quality_curve(unit_base, 0.5)[1]
local ts_celsius = ts.quality_curve({fluid = "R134a", pressure_min = 1e5,
  pressure_max = 2e5, initial_intervals = 2, max_depth = 1,
  temperature_axis_unit = "celsius"}, 0.5)[1]
assert_close(ts_celsius.y, ts_kelvin.y - 273.15, 1e-12,
  "Celsius coordinate offset")

-- Diagram samplers return explicit connected components.  The serializer
-- consumes `_break_before` markers and therefore cannot bridge an invalid
-- CoolProp domain with a fictitious line segment.
local segmented = ph.isentrope_curve({fluid = "R134a",
  pressure_min = 1e3, pressure_max = 1e9, initial_intervals = 4,
  max_depth = 4, domain_policy = "ignore"}, 1700)
assert(type(segmented.segments) == "table"
    and segmented.domain_gap_count == math.max(0, #segmented.segments - 1)
    and type(segmented.omitted_sample_count) == "number",
  "adaptive curves expose connected-domain metadata")

-- Diagram construction is deliberately pure-fluid-only.  This restriction
-- does not leak into the low-level CoolProp wrappers.
local mixture = "HEOS::R32[0.697615]&R125[0.302385]"
assert(finite(lcp.propsSI("H", "P", 1e5, "T", 300, mixture)),
  "low-level wrappers must remain mixture-capable")
assert_error_contains(function()
  ph.axis_style({fluid = mixture, pressure_min = 1e5,
    pressure_max = 2e6})
end, "diagrams require a pure fluid", "mixture diagram boundary")
assert_error_contains(function()
  pv.axis_style({fluid = "R407C", pressure_min = 1e5,
    pressure_max = 2e6})
end, "diagrams require a pure fluid", "predefined blend diagram boundary")

-- Enthalpy and entropy coordinates in a document share one immutable
-- CoolProp reference-state convention per fluid.
assert(lcp.set_reference_state("Propane", "DEF") == "DEF")
ph.axis_style({fluid = "Propane", pressure_min = 1e5,
  pressure_max = 2e6, reference_state = "DEF"})
assert(lcp.reference_state("Propane") == "DEF")
assert_error_contains(function()
  lcp.set_reference_state("Propane", "IIR")
end, "is locked to DEF", "locked reference-state contract")
local _, reference_metadata = ph.process_points({fluid = "R134a",
  type = "isobar", pressure = "1bar", from = "quality=0",
  to = "quality=1"})
assert(reference_metadata.reference_state == "DEF",
  "process metadata records the reference-state convention")

-- TS, HS and PT need the same sampled records both to derive automatic axis
-- limits and to serialize plots.  The TeX bridge must consume those records
-- exactly once, without changing a plotted coordinate or an autonode path.
local prepared_specs = {
  {code = "TS", generator = "ts_quality_curve",
    opts = {fluid = "R134a", pressure_min = 1e5, pressure_max = 1e6,
      quality = true, q_mode = "list", q_values = "0.5", labels = true,
      initial_intervals = 4, max_depth = 2}},
  {code = "HS", generator = "hs_quality_curve",
    opts = {fluid = "R134a", pressure_min = 1e5, pressure_max = 1e6,
      quality = true, q_mode = "list", q_values = "0.5", labels = true,
      initial_intervals = 4, max_depth = 2}},
  {code = "PT", generator = "pt_phase_envelope_curve",
    opts = {fluid = "R134a", pressure_min = 1e5, pressure_max = 1e6,
      phase_envelope = true, labels = true,
      initial_intervals = 4, max_depth = 2}},
}
local captured = {}
tex = {sprint = function(value) captured[#captured + 1] = value end}
for _, spec in ipairs(prepared_specs) do
  local implementation = lcp.diagram.get_type(spec.code)
  local expected_style = "\\pgfplotsset{every axis/.append style={"
    .. implementation.axis_style(spec.opts) .. "}}"
  local expected_plots = implementation.plots(spec.opts)
  local original = lcp.diagram[spec.generator]
  local calls = 0
  lcp.diagram[spec.generator] = function(...)
    calls = calls + 1
    return original(...)
  end
  captured = {}
  lcp.tex.print_diagram(spec.code, "axis_style", spec.opts)
  lcp.tex.print_diagram(spec.code, "plots", spec.opts)
  lcp.diagram[spec.generator] = original
  assert(calls == 1,
    spec.code .. " high-level TeX diagram must sample its curve once")
  assert(captured[1] == expected_style,
    spec.code .. " prepared axis bounds must match uncached output")
  assert(captured[2] == expected_plots,
    spec.code .. " prepared plot and autonode path must be byte-identical")
end

-- The prepared records are one-shot and keyed by every input option.  A
-- changed family value after axis preparation must trigger new sampling.
local changed_opts = {fluid = "R134a", pressure_min = 1e5,
  pressure_max = 1e6, quality = true, q_mode = "list", q_values = "0.5",
  initial_intervals = 4, max_depth = 2}
captured = {}
lcp.tex.print_diagram("TS", "axis_style", changed_opts)
changed_opts.q_values = "0.2"
local changed_expected = lcp.diagram.ts.plots(changed_opts)
lcp.tex.print_diagram("TS", "plots", changed_opts)
assert(captured[2] == changed_expected,
  "changed curve options must invalidate prepared axis records")

io.write("LuaCoolProp numerical regression tests passed\n")
