966 lines
35 KiB
Rust
966 lines
35 KiB
Rust
//! Engineering facts for reviewed port operations. This formats the concrete
|
|
//! planner commands; it neither infers interface roles nor executes a second edit.
|
|
use oa_core::*;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
/// Explain a port plan, including endpoints reused by its concrete commands.
|
|
/// An empty plan has no subject, so its existing context belongs in the caller.
|
|
pub fn change_rows(before: &Project, commands: &[Command]) -> Vec<String> {
|
|
let mut pending: Vec<_> = commands.iter().rev().collect();
|
|
let mut flat = Vec::new();
|
|
while let Some(command) = pending.pop() {
|
|
if let Command::Batch(children) = command {
|
|
pending.extend(children.iter().rev());
|
|
} else {
|
|
flat.push(command);
|
|
}
|
|
}
|
|
if flat.is_empty() {
|
|
return vec!["Already represented; no model changes required.".into()];
|
|
}
|
|
// Overlay borrowed records for names of newly created endpoints, without
|
|
// cloning or validating the entire project on every view render.
|
|
let mut context = Context::new(before);
|
|
for command in &flat {
|
|
match command {
|
|
Command::CreateElement(e) | Command::EditElement(_, e) => {
|
|
context.elements.insert(e.id, e);
|
|
}
|
|
Command::CreateRelation(r) | Command::EditRelation(_, r) => {
|
|
context.relations.insert(r.id, r);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let original = Context::new(before);
|
|
let mut rows = Vec::new();
|
|
let mut reused_ports = BTreeSet::new();
|
|
let mut reused_exchanges = BTreeSet::new();
|
|
let mut reused_allocations = BTreeSet::new();
|
|
for command in flat {
|
|
match command {
|
|
Command::CreateElement(e) => port_rows(&context, None, e, "Create", &mut rows),
|
|
Command::EditElement(id, e) => {
|
|
port_rows(&context, before.elements.get(id), e, "Update", &mut rows);
|
|
}
|
|
Command::CreateRelation(r) | Command::EditRelation(_, r) => {
|
|
let old = if let Command::EditRelation(id, _) = command {
|
|
before.relations.get(id)
|
|
} else {
|
|
None
|
|
};
|
|
rows.push(format!(
|
|
"{} {}: {}",
|
|
if old.is_some() { "Update" } else { "Create" },
|
|
r.kind(),
|
|
context.label(r.id)
|
|
));
|
|
relation_rows(&context, old, r, &mut rows);
|
|
for id in references::relation_references(r) {
|
|
if context.element(id).is_some_and(|e| e.kind() == Kind::Port) {
|
|
reused_ports.insert(id);
|
|
}
|
|
}
|
|
if let RelationBody::ExchangeAllocation { source, target } = r.body {
|
|
// Existing facts omitted by an idempotent planner still
|
|
// matter to the reviewed correspondence.
|
|
for exchange in [source, target] {
|
|
reused_exchanges.insert(exchange);
|
|
if let Some(exchange) = context.relation(exchange) {
|
|
for id in references::relation_references(exchange) {
|
|
if context.element(id).is_some_and(|e| e.kind() == Kind::Port) {
|
|
reused_ports.insert(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let (Some(boundary), Some(functional)) =
|
|
(context.relation(source), context.relation(target))
|
|
&& let (
|
|
RelationBody::BoundaryExchange {
|
|
source: a,
|
|
target: b,
|
|
..
|
|
},
|
|
RelationBody::FunctionalExchange {
|
|
source_port,
|
|
target_port,
|
|
..
|
|
},
|
|
) = (&boundary.body, &functional.body)
|
|
{
|
|
for allocation in context.all_relations().filter(|allocation| {
|
|
matches!(allocation.body, RelationBody::PortAllocation { source, target }
|
|
if [*a, *b].contains(&source)
|
|
&& [*source_port, *target_port].contains(&target))
|
|
}) {
|
|
reused_allocations.insert(allocation.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => rows.push(
|
|
"Additional command requires separate review; it is outside this port preview."
|
|
.into(),
|
|
),
|
|
}
|
|
}
|
|
for id in reused_exchanges {
|
|
if !context.relations.contains_key(&id)
|
|
&& let Some(exchange) = before.relations.get(&id)
|
|
{
|
|
rows.push(format!("Reuse {}: {}", exchange.kind(), original.label(id)));
|
|
relation_rows(&original, None, exchange, &mut rows);
|
|
}
|
|
}
|
|
for id in reused_ports {
|
|
if !context.elements.contains_key(&id)
|
|
&& let Some(port) = before.elements.get(&id)
|
|
{
|
|
port_rows(&original, None, port, "Reuse", &mut rows);
|
|
}
|
|
}
|
|
for id in reused_allocations {
|
|
if !context.relations.contains_key(&id)
|
|
&& let Some(allocation) = before.relations.get(&id)
|
|
{
|
|
rows.push(format!("Reuse port allocation: {}", original.label(id)));
|
|
relation_rows(&original, None, allocation, &mut rows);
|
|
}
|
|
}
|
|
rows
|
|
}
|
|
|
|
/// Explain current item origins and every incident FE before resolution, split,
|
|
/// or synchronization. Retained uses refer to the original primary identity.
|
|
pub fn port_context_rows(project: &Project, port: Id) -> Vec<String> {
|
|
let context = Context::new(project);
|
|
let Some(element) = project.elements.get(&port) else {
|
|
return vec![format!("Missing port: {port}")];
|
|
};
|
|
let Some(SharedBody::Port { contract, legacy }) = element.shared() else {
|
|
return vec![];
|
|
};
|
|
let mut rows = vec![format!("Current port: {}", context.endpoint(port))];
|
|
let mut origins: BTreeMap<Id, Vec<String>> = BTreeMap::new();
|
|
if let Some((side, items)) = function_items(contract) {
|
|
rows.push(format!(
|
|
"Current {side} declaration: {}",
|
|
context.items(items)
|
|
));
|
|
for item in items {
|
|
origins
|
|
.entry(*item)
|
|
.or_default()
|
|
.push(format!("declared {side}"));
|
|
}
|
|
}
|
|
if let Some(legacy) = legacy {
|
|
rows.push(legacy_line(&context, legacy));
|
|
if let Some(interface) = legacy.interface {
|
|
for item in data::interface_items(project, interface) {
|
|
origins.entry(item).or_default().push(format!(
|
|
"preserved interface {} (suggestion only; not an active typed contract)",
|
|
context.label(interface)
|
|
));
|
|
}
|
|
}
|
|
}
|
|
for relation in project.relations.values() {
|
|
if let RelationBody::FunctionalExchange {
|
|
source_port,
|
|
target_port,
|
|
items,
|
|
..
|
|
} = &relation.body
|
|
{
|
|
for (endpoint, role) in [(*source_port, "output"), (*target_port, "input")] {
|
|
if endpoint == port {
|
|
rows.push(format!(
|
|
"Affected functional exchange: {} · {role} endpoint {} · carried items: {}",
|
|
context.label(relation.id),
|
|
context.label(port),
|
|
context.items(items)
|
|
));
|
|
for item in items {
|
|
origins.entry(*item).or_default().push(format!(
|
|
"carried by {} at this {role} endpoint",
|
|
context.label(relation.id)
|
|
));
|
|
}
|
|
}
|
|
}
|
|
} else if references::relation_references(relation).contains(&port) {
|
|
rows.push(format!(
|
|
"Retained {}: {}",
|
|
relation.kind(),
|
|
context.label(relation.id)
|
|
));
|
|
relation_rows(&context, None, relation, &mut rows);
|
|
}
|
|
}
|
|
for (item, sources) in origins {
|
|
rows.push(format!(
|
|
"Item origin · {}: {}",
|
|
context.label(item),
|
|
sources.join("; ")
|
|
));
|
|
}
|
|
for user in project
|
|
.elements
|
|
.values()
|
|
.filter(|e| e.id != port && references::element_references(e).contains(&port))
|
|
{
|
|
rows.push(format!(
|
|
"Retained {} reference: {} → {}",
|
|
user.kind(),
|
|
context.label(user.id),
|
|
context.label(port)
|
|
));
|
|
}
|
|
for artifact in project.artifact_links.values().filter(|a| a.owner == port) {
|
|
rows.push(format!(
|
|
"Retained artifact: {} · @{} · {:?} · {} · owner {}",
|
|
artifact.name,
|
|
artifact.alias,
|
|
artifact.relation,
|
|
artifact.uri,
|
|
context.label(port)
|
|
));
|
|
}
|
|
for trace in project
|
|
.traces
|
|
.values()
|
|
.filter(|t| t.oa == port || t.sa == port)
|
|
{
|
|
rows.push(format!(
|
|
"Retained trace: {} · @{} · OA {} → SA {}",
|
|
trace.name,
|
|
trace.alias,
|
|
context.label(trace.oa),
|
|
context.label(trace.sa)
|
|
));
|
|
}
|
|
rows
|
|
}
|
|
|
|
struct Context<'a> {
|
|
before: &'a Project,
|
|
elements: BTreeMap<Id, &'a Element>,
|
|
relations: BTreeMap<Id, &'a Relation>,
|
|
}
|
|
impl<'a> Context<'a> {
|
|
fn new(before: &'a Project) -> Self {
|
|
Self {
|
|
before,
|
|
elements: BTreeMap::new(),
|
|
relations: BTreeMap::new(),
|
|
}
|
|
}
|
|
fn element(&self, id: Id) -> Option<&Element> {
|
|
self.elements
|
|
.get(&id)
|
|
.copied()
|
|
.or_else(|| self.before.elements.get(&id))
|
|
}
|
|
fn relation(&self, id: Id) -> Option<&Relation> {
|
|
self.relations
|
|
.get(&id)
|
|
.copied()
|
|
.or_else(|| self.before.relations.get(&id))
|
|
}
|
|
fn all_relations(&self) -> impl Iterator<Item = &Relation> {
|
|
self.before
|
|
.relations
|
|
.values()
|
|
.filter(|r| !self.relations.contains_key(&r.id))
|
|
.chain(self.relations.values().copied())
|
|
}
|
|
fn label(&self, id: Id) -> String {
|
|
if let Some(e) = self.element(id) {
|
|
format!("{} · @{}", e.name, e.alias)
|
|
} else if let Some(r) = self.relation(id) {
|
|
format!("{} · @{}", r.name, r.alias)
|
|
} else {
|
|
format!("{} · {id}", self.before.named(id))
|
|
}
|
|
}
|
|
fn endpoint(&self, id: Id) -> String {
|
|
let mut label = self.label(id);
|
|
if let Some(port) = self.element(id).filter(|e| e.kind() == Kind::Port)
|
|
&& let Some(owner) = port.parent
|
|
{
|
|
label.push_str(&format!(" (owner {})", self.label(owner)));
|
|
}
|
|
label
|
|
}
|
|
fn items(&self, items: &[Id]) -> String {
|
|
if items.is_empty() {
|
|
"none (empty contract)".into()
|
|
} else {
|
|
items
|
|
.iter()
|
|
.map(|id| self.label(*id))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn function_items(contract: &PortContract) -> Option<(&'static str, &[Id])> {
|
|
match contract {
|
|
PortContract::FunctionInput { incoming_items } => Some(("incoming", incoming_items)),
|
|
PortContract::FunctionOutput { outgoing_items } => Some(("outgoing", outgoing_items)),
|
|
_ => None,
|
|
}
|
|
}
|
|
fn contract_name(contract: &PortContract) -> String {
|
|
match contract {
|
|
PortContract::FunctionInput { .. } => "Function input".into(),
|
|
PortContract::FunctionOutput { .. } => "Function output".into(),
|
|
PortContract::Component { kind, orientation } => {
|
|
if *kind == ComponentPortKind::Standard {
|
|
"Component Standard (no Flow orientation)".into()
|
|
} else {
|
|
format!("Component Flow · {orientation:?}")
|
|
}
|
|
}
|
|
PortContract::Legacy => "Legacy (unresolved)".into(),
|
|
}
|
|
}
|
|
fn legacy_line(context: &Context<'_>, legacy: &LegacyPortContract) -> String {
|
|
format!(
|
|
"Preserved generic association: {:?}; interface {}. This does not declare a provided or required role.",
|
|
legacy.direction,
|
|
legacy
|
|
.interface
|
|
.map(|id| context.label(id))
|
|
.unwrap_or_else(|| "none".into())
|
|
)
|
|
}
|
|
fn role_qualifier(context: &Context<'_>, source: Id) -> &'static str {
|
|
match context.element(source).and_then(Element::shared) {
|
|
Some(SharedBody::Port { contract, legacy })
|
|
if *contract == PortContract::Legacy
|
|
|| (contract.is_function() && legacy.is_some()) =>
|
|
{
|
|
" (preserved compatibility declaration)"
|
|
}
|
|
_ => "",
|
|
}
|
|
}
|
|
fn item_change_rows(
|
|
context: &Context<'_>,
|
|
label: &str,
|
|
old: Option<&[Id]>,
|
|
new: &[Id],
|
|
rows: &mut Vec<String>,
|
|
) {
|
|
match old {
|
|
Some(old) if old != new => rows.push(format!(
|
|
"{label}: {} → {}",
|
|
Context::new(context.before).items(old),
|
|
context.items(new)
|
|
)),
|
|
_ => rows.push(format!("{label}: {}", context.items(new))),
|
|
}
|
|
}
|
|
fn port_rows(
|
|
context: &Context<'_>,
|
|
old: Option<&Element>,
|
|
port: &Element,
|
|
verb: &str,
|
|
rows: &mut Vec<String>,
|
|
) {
|
|
let Some(SharedBody::Port { contract, legacy }) = port.shared() else {
|
|
rows.push(format!(
|
|
"{verb} {}: {}",
|
|
port.kind(),
|
|
context.label(port.id)
|
|
));
|
|
return;
|
|
};
|
|
rows.push(format!("{verb} port: {}", context.endpoint(port.id)));
|
|
let previous = old.and_then(port_contract);
|
|
if let Some(previous) =
|
|
previous.filter(|previous| contract_name(previous) != contract_name(contract))
|
|
{
|
|
rows.push(format!(
|
|
"Port contract: {} → {}",
|
|
contract_name(previous),
|
|
contract_name(contract)
|
|
));
|
|
} else {
|
|
rows.push(format!("Port contract: {}", contract_name(contract)));
|
|
}
|
|
if let Some((side, items)) = function_items(contract) {
|
|
item_change_rows(
|
|
context,
|
|
&format!("{side} items"),
|
|
previous.and_then(function_items).map(|(_, items)| items),
|
|
items,
|
|
rows,
|
|
);
|
|
}
|
|
if let Some(legacy) = legacy {
|
|
rows.push(legacy_line(context, legacy));
|
|
}
|
|
for role in context.all_relations().filter(|r| {
|
|
r.source() == port.id
|
|
&& matches!(
|
|
r.kind(),
|
|
RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement
|
|
)
|
|
}) {
|
|
rows.push(format!(
|
|
"{} interface {} · declaration {}{}",
|
|
if role.kind() == RelationKind::InterfaceProvision {
|
|
"Provides"
|
|
} else {
|
|
"Requires"
|
|
},
|
|
context.label(role.target()),
|
|
context.label(role.id),
|
|
role_qualifier(context, port.id)
|
|
));
|
|
}
|
|
}
|
|
fn relation_rows(
|
|
context: &Context<'_>,
|
|
old: Option<&Relation>,
|
|
relation: &Relation,
|
|
rows: &mut Vec<String>,
|
|
) {
|
|
let original = Context::new(context.before);
|
|
match &relation.body {
|
|
RelationBody::FunctionalExchange {
|
|
source,
|
|
target,
|
|
source_port,
|
|
target_port,
|
|
items,
|
|
} => {
|
|
rows.push(format!(
|
|
"Functions: {} → {}",
|
|
context.label(*source),
|
|
context.label(*target)
|
|
));
|
|
let previous = old.and_then(|r| match r.body {
|
|
RelationBody::FunctionalExchange {
|
|
source_port,
|
|
target_port,
|
|
..
|
|
} => Some((source_port, target_port)),
|
|
_ => None,
|
|
});
|
|
for (label, port, prior) in [
|
|
("Output endpoint", *source_port, previous.map(|p| p.0)),
|
|
("Input endpoint", *target_port, previous.map(|p| p.1)),
|
|
] {
|
|
rows.push(if let Some(prior) = prior.filter(|prior| *prior != port) {
|
|
format!(
|
|
"{label}: {} → {}",
|
|
original.endpoint(prior),
|
|
context.endpoint(port)
|
|
)
|
|
} else {
|
|
format!("{label}: {}", context.endpoint(port))
|
|
});
|
|
}
|
|
item_change_rows(
|
|
context,
|
|
"Carried items",
|
|
old.map(|r| r.body.items()),
|
|
items,
|
|
rows,
|
|
);
|
|
}
|
|
RelationBody::BoundaryExchange {
|
|
source,
|
|
target,
|
|
oriented,
|
|
items,
|
|
} => {
|
|
rows.push(format!(
|
|
"Component endpoints: {} {} {}",
|
|
context.endpoint(*source),
|
|
if *oriented { "→" } else { "—" },
|
|
context.endpoint(*target)
|
|
));
|
|
let orientation = |directed| if directed { "directed" } else { "unoriented" };
|
|
let previous = old.and_then(|r| match r.body {
|
|
RelationBody::BoundaryExchange { oriented, .. } => Some(oriented),
|
|
_ => None,
|
|
});
|
|
rows.push(
|
|
if let Some(previous) = previous.filter(|previous| previous != oriented) {
|
|
format!(
|
|
"Exchange orientation: {} → {}",
|
|
orientation(previous),
|
|
orientation(*oriented)
|
|
)
|
|
} else {
|
|
format!(
|
|
"Exchange orientation: {} (independent of interface roles)",
|
|
orientation(*oriented)
|
|
)
|
|
},
|
|
);
|
|
item_change_rows(
|
|
context,
|
|
"Boundary carried items",
|
|
old.map(|r| r.body.items()),
|
|
items,
|
|
rows,
|
|
);
|
|
}
|
|
RelationBody::PortAllocation { source, target } => {
|
|
rows.push(format!(
|
|
"Component port {} allocates function port {}",
|
|
context.endpoint(*source),
|
|
context.endpoint(*target)
|
|
));
|
|
if let (Some(component), Some(function)) = (
|
|
context.element(*source).and_then(|port| port.parent),
|
|
context.element(*target).and_then(|port| port.parent),
|
|
) {
|
|
for allocation in context.all_relations().filter(|r| {
|
|
r.kind() == RelationKind::Allocation
|
|
&& r.source() == component
|
|
&& r.target() == function
|
|
}) {
|
|
rows.push(format!(
|
|
"Actual work allocation: {} performs {} · {}",
|
|
context.label(component),
|
|
context.label(function),
|
|
context.label(allocation.id)
|
|
));
|
|
}
|
|
}
|
|
}
|
|
RelationBody::ExchangeAllocation { source, target } => {
|
|
rows.push(format!(
|
|
"Component exchange {} implements functional exchange {}",
|
|
context.label(*source),
|
|
context.label(*target)
|
|
));
|
|
}
|
|
RelationBody::InterfaceProvision { source, target }
|
|
| RelationBody::InterfaceRequirement { source, target } => {
|
|
rows.push(format!(
|
|
"{} {} interface {}{}",
|
|
context.endpoint(*source),
|
|
if relation.kind() == RelationKind::InterfaceProvision {
|
|
"provides"
|
|
} else {
|
|
"requires"
|
|
},
|
|
context.label(*target),
|
|
role_qualifier(context, *source)
|
|
));
|
|
}
|
|
_ => rows.push(format!(
|
|
"{} → {}",
|
|
context.label(relation.source()),
|
|
context.label(relation.target())
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn add(p: &mut Project, kind: Kind, name: &str) -> Id {
|
|
p.add_element(kind, Layer::Sa, name, None).unwrap()
|
|
}
|
|
fn port(p: &mut Project, owner: Id, name: &str, contract: PortContract) -> Id {
|
|
let id = Id::new_v4();
|
|
let element = make_port(p, id, owner, name, contract).unwrap();
|
|
p.elements.insert(id, element);
|
|
id
|
|
}
|
|
fn exchange(p: &mut Project, source: Id, target: Id, items: Vec<Id>) -> (Id, Vec<Command>) {
|
|
let id = Id::new_v4();
|
|
let commands = plan_functional_exchange(
|
|
p,
|
|
&FunctionalExchangeRequest {
|
|
id,
|
|
alias: p.fresh_alias("report", "exchange", id),
|
|
name: "Transfer report".into(),
|
|
description: String::new(),
|
|
source,
|
|
target,
|
|
source_port: PortChoice::Auto,
|
|
target_port: PortChoice::Auto,
|
|
items,
|
|
},
|
|
)
|
|
.unwrap();
|
|
(id, commands)
|
|
}
|
|
struct Fixture {
|
|
project: Project,
|
|
source: Id,
|
|
target: Id,
|
|
first: Id,
|
|
second: Id,
|
|
}
|
|
fn fixture() -> Fixture {
|
|
let mut p = Project::empty();
|
|
let source = add(&mut p, Kind::Function, "Report incident");
|
|
let target = add(&mut p, Kind::Function, "Assess incident");
|
|
let actor = add(&mut p, Kind::SystemActor, "Operator");
|
|
let system = add(&mut p, Kind::System, "Rescue system");
|
|
let first = add(&mut p, Kind::ExchangeItem, "Incident report");
|
|
let second = add(&mut p, Kind::ExchangeItem, "Assessment request");
|
|
p.relate(RelationKind::Allocation, "Reports", actor, source);
|
|
p.relate(RelationKind::Allocation, "Assesses", system, target);
|
|
port(
|
|
&mut p,
|
|
source,
|
|
"Report output",
|
|
PortContract::FunctionOutput {
|
|
outgoing_items: vec![first, second],
|
|
},
|
|
);
|
|
port(
|
|
&mut p,
|
|
target,
|
|
"Report input",
|
|
PortContract::FunctionInput {
|
|
incoming_items: vec![first, second],
|
|
},
|
|
);
|
|
Fixture {
|
|
project: p,
|
|
source,
|
|
target,
|
|
first,
|
|
second,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn functional_creation_identifies_new_and_reused_endpoints_and_owners() {
|
|
let Fixture {
|
|
mut project,
|
|
source,
|
|
target,
|
|
first,
|
|
..
|
|
} = fixture();
|
|
let removed = project
|
|
.children(target)
|
|
.find(|e| e.kind() == Kind::Port)
|
|
.unwrap()
|
|
.id;
|
|
project.elements.remove(&removed);
|
|
let (_, commands) = exchange(&mut project, source, target, vec![first]);
|
|
let before = project.clone();
|
|
let rows = change_rows(&project, &commands);
|
|
let text = rows.join("\n");
|
|
assert!(text.contains("Create port:"));
|
|
assert!(text.contains(
|
|
"Reuse port: Report output · @report_output (owner Report incident · @report_incident)"
|
|
));
|
|
assert!(text.contains("Output endpoint: Report output · @report_output"));
|
|
assert!(
|
|
text.contains("Input endpoint:")
|
|
&& text.contains("owner Assess incident · @assess_incident")
|
|
);
|
|
assert!(text.contains("Carried items: Incident report · @incident_report"));
|
|
assert!(text.contains("Port contract: Function input"));
|
|
assert_eq!(project, before, "Preview must not apply a plan");
|
|
assert!(execute_batch(&project, &commands).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn reused_boundary_shows_item_union_and_existing_allocating_facts() {
|
|
let Fixture {
|
|
mut project,
|
|
source,
|
|
target,
|
|
first,
|
|
second,
|
|
} = fixture();
|
|
let (fe, commands) = exchange(&mut project, source, target, vec![first]);
|
|
project = execute_batch(&project, &commands).unwrap();
|
|
let boundary = Id::new_v4();
|
|
let generated = plan_component_exchange(
|
|
&project,
|
|
&ComponentExchangeRequest {
|
|
functional_exchange: fe,
|
|
boundary: ComponentExchangeChoice::Create {
|
|
id: boundary,
|
|
alias: "implementation".into(),
|
|
name: "Incident interface".into(),
|
|
description: "Keep this prose".into(),
|
|
port_kind: ComponentPortKind::Standard,
|
|
oriented: false,
|
|
source_port: PortChoice::Auto,
|
|
target_port: PortChoice::Auto,
|
|
},
|
|
},
|
|
)
|
|
.unwrap();
|
|
project = execute_batch(&project, &generated).unwrap();
|
|
let (other_fe, commands) = exchange(&mut project, source, target, vec![second]);
|
|
project = execute_batch(&project, &commands).unwrap();
|
|
let interface = add(&mut project, Kind::Interface, "Incident service");
|
|
project.relate(
|
|
RelationKind::InterfaceItemAllocation,
|
|
"First item",
|
|
interface,
|
|
first,
|
|
);
|
|
project.relate(
|
|
RelationKind::InterfaceItemAllocation,
|
|
"Second item",
|
|
interface,
|
|
second,
|
|
);
|
|
let connection = &project.relations[&boundary];
|
|
let (a, b) = (connection.source(), connection.target());
|
|
project.relate(
|
|
RelationKind::InterfaceRequirement,
|
|
"Client requirement",
|
|
a,
|
|
interface,
|
|
);
|
|
project.relate(
|
|
RelationKind::InterfaceProvision,
|
|
"Service provision",
|
|
b,
|
|
interface,
|
|
);
|
|
let commands = plan_component_exchange(
|
|
&project,
|
|
&ComponentExchangeRequest {
|
|
functional_exchange: other_fe,
|
|
boundary: ComponentExchangeChoice::Reuse(boundary),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let rows = change_rows(&project, &commands);
|
|
let text = rows.join("\n");
|
|
assert!(text.contains("Boundary carried items: Incident report · @incident_report → Incident report · @incident_report, Assessment request · @assessment_request"));
|
|
assert!(text.contains("Exchange orientation: unoriented"));
|
|
assert!(text.contains("Port contract: Component Standard (no Flow orientation)"));
|
|
assert_eq!(
|
|
rows.iter()
|
|
.filter(|row| row.starts_with("Reuse port allocation:"))
|
|
.count(),
|
|
2
|
|
);
|
|
assert!(text.contains("allocates function port Report output · @report_output (owner Report incident · @report_incident)"));
|
|
assert!(text.contains("implements functional exchange Transfer report"));
|
|
assert!(text.contains("Actual work allocation: Operator · @operator performs Report incident · @report_incident"));
|
|
assert!(text.contains("Requires interface Incident service · @incident_service · declaration Client requirement"));
|
|
assert!(text.contains("Provides interface Incident service · @incident_service · declaration Service provision"));
|
|
assert_eq!(project.relations[&boundary].description, "Keep this prose");
|
|
project = execute_batch(&project, &commands).unwrap();
|
|
let (third_fe, commands) = exchange(&mut project, source, target, vec![first]);
|
|
project = execute_batch(&project, &commands).unwrap();
|
|
let commands = plan_component_exchange(
|
|
&project,
|
|
&ComponentExchangeRequest {
|
|
functional_exchange: third_fe,
|
|
boundary: ComponentExchangeChoice::Reuse(boundary),
|
|
},
|
|
)
|
|
.unwrap();
|
|
let rows = change_rows(&project, &commands);
|
|
assert!(
|
|
rows.iter()
|
|
.any(|row| row == "Reuse Boundary exchange: Incident interface · @implementation")
|
|
);
|
|
assert!(
|
|
!rows
|
|
.iter()
|
|
.any(|row| row.starts_with("Create Boundary exchange:"))
|
|
);
|
|
assert_eq!(change_rows(&project, &[Command::Batch(commands)]), rows);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_split_discloses_endpoint_routing_item_origins_and_retained_evidence() {
|
|
let Fixture {
|
|
mut project,
|
|
source,
|
|
target,
|
|
first,
|
|
..
|
|
} = fixture();
|
|
let interface = add(&mut project, Kind::Interface, "Preserved contract");
|
|
project.relate(
|
|
RelationKind::InterfaceItemAllocation,
|
|
"Allocated item",
|
|
interface,
|
|
first,
|
|
);
|
|
let legacy = port(
|
|
&mut project,
|
|
source,
|
|
"Shared legacy endpoint",
|
|
PortContract::Legacy,
|
|
);
|
|
project.elements.get_mut(&legacy).unwrap().body = ElementBody::shared_in(
|
|
Layer::Sa,
|
|
SharedBody::Port {
|
|
contract: PortContract::Legacy,
|
|
legacy: Some(LegacyPortContract {
|
|
direction: PortDirection::InOut,
|
|
interface: Some(interface),
|
|
}),
|
|
},
|
|
);
|
|
let input = project
|
|
.children(target)
|
|
.find(|e| matches!(port_contract(e), Some(PortContract::FunctionInput { .. })))
|
|
.unwrap()
|
|
.id;
|
|
let output = port(
|
|
&mut project,
|
|
target,
|
|
"Reply output",
|
|
PortContract::FunctionOutput {
|
|
outgoing_items: vec![first],
|
|
},
|
|
);
|
|
let outgoing = project.relate(
|
|
RelationKind::FunctionalExchange,
|
|
"Outgoing report",
|
|
source,
|
|
target,
|
|
);
|
|
project.relations.get_mut(&outgoing).unwrap().body = RelationBody::FunctionalExchange {
|
|
source,
|
|
target,
|
|
source_port: legacy,
|
|
target_port: input,
|
|
items: vec![first],
|
|
};
|
|
let incoming = project.relate(
|
|
RelationKind::FunctionalExchange,
|
|
"Incoming report",
|
|
target,
|
|
source,
|
|
);
|
|
project.relations.get_mut(&incoming).unwrap().body = RelationBody::FunctionalExchange {
|
|
source: target,
|
|
target: source,
|
|
source_port: output,
|
|
target_port: legacy,
|
|
items: vec![first],
|
|
};
|
|
let constraint = add(&mut project, Kind::Constraint, "Bandwidth budget");
|
|
project.elements.get_mut(&constraint).unwrap().body = ElementBody::shared_in(
|
|
Layer::Sa,
|
|
SharedBody::Constraint {
|
|
expression: "Bandwidth is bounded".into(),
|
|
constrained: vec![legacy],
|
|
},
|
|
);
|
|
let artifact = Id::new_v4();
|
|
project.artifact_links.insert(
|
|
artifact,
|
|
ArtifactLink {
|
|
id: artifact,
|
|
alias: "schematic".into(),
|
|
name: "Electrical schematic".into(),
|
|
description: String::new(),
|
|
owner: legacy,
|
|
relation: ArtifactRelation::References,
|
|
uri: "./hardware/board.kicad_sch".into(),
|
|
revision: None,
|
|
fragment: None,
|
|
},
|
|
);
|
|
let activity = project
|
|
.add_element(Kind::Activity, Layer::Oa, "Operational reporting", None)
|
|
.unwrap();
|
|
let trace = Id::new_v4();
|
|
project.traces.insert(
|
|
trace,
|
|
Trace {
|
|
id: trace,
|
|
alias: "reporting_trace".into(),
|
|
name: "Reporting correspondence".into(),
|
|
description: String::new(),
|
|
oa: activity,
|
|
sa: legacy,
|
|
kind: TraceKind::Manual,
|
|
baseline: None,
|
|
},
|
|
);
|
|
let context = port_context_rows(&project, legacy).join("\n");
|
|
assert!(context.contains("Affected functional exchange: Outgoing report"));
|
|
assert!(context.contains("Affected functional exchange: Incoming report"));
|
|
assert!(
|
|
context.contains("Item origin · Incident report · @incident_report:")
|
|
&& context.contains("preserved interface Preserved contract")
|
|
);
|
|
assert!(context.contains("suggestion only; not an active typed contract"));
|
|
assert!(context.contains("Retained Constraint reference: Bandwidth budget"));
|
|
assert!(context.contains("Retained artifact: Electrical schematic · @schematic"));
|
|
assert!(context.contains("Retained trace: Reporting correspondence · @reporting_trace"));
|
|
let commands = plan_legacy_work_port_split(
|
|
&project,
|
|
&LegacyWorkPortSplitRequest {
|
|
port: legacy,
|
|
primary: FunctionPortSide::Output,
|
|
incoming_items: vec![first],
|
|
outgoing_items: vec![first],
|
|
},
|
|
)
|
|
.unwrap();
|
|
let text = change_rows(&project, &commands).join("\n");
|
|
let opposite = Id::new_v5(&legacy, b"split-opposite-port");
|
|
let candidate = execute_batch(&project, &commands).unwrap();
|
|
assert!(text.contains("Port contract: Legacy (unresolved) → Function output"));
|
|
assert!(text.contains(&format!(
|
|
"Input endpoint: {} → {}",
|
|
Context::new(&project).endpoint(legacy),
|
|
Context::new(&candidate).endpoint(opposite)
|
|
)));
|
|
assert!(text.contains("Create port:") && text.contains("Port contract: Function input"));
|
|
assert_eq!(candidate.artifact_links[&artifact].owner, legacy);
|
|
assert_eq!(candidate.traces[&trace].sa, legacy);
|
|
}
|
|
|
|
#[test]
|
|
fn synchronization_preview_exposes_contract_shrinkage_and_current_declaration_origin() {
|
|
let Fixture {
|
|
project,
|
|
source,
|
|
first,
|
|
second,
|
|
..
|
|
} = fixture();
|
|
let output = project
|
|
.children(source)
|
|
.find(|e| e.kind() == Kind::Port)
|
|
.unwrap()
|
|
.id;
|
|
let commands = plan_function_port_items(&project, output, vec![first]).unwrap();
|
|
let context = port_context_rows(&project, output).join("\n");
|
|
assert!(
|
|
context.contains(
|
|
"Item origin · Assessment request · @assessment_request: declared outgoing"
|
|
)
|
|
);
|
|
let text = change_rows(&project, &commands).join("\n");
|
|
assert!(text.contains("outgoing items: Incident report · @incident_report, Assessment request · @assessment_request → Incident report · @incident_report"));
|
|
let candidate = execute_batch(&project, &commands).unwrap();
|
|
assert!(
|
|
matches!(port_contract(&candidate.elements[&output]), Some(PortContract::FunctionOutput { outgoing_items }) if outgoing_items == &[first])
|
|
);
|
|
assert!(project.elements.contains_key(&second));
|
|
assert_eq!(
|
|
change_rows(&project, &[]),
|
|
["Already represented; no model changes required."]
|
|
);
|
|
}
|
|
}
|