Integrate typed port authoring and reviewable exchange workflows
This commit is contained in:
@@ -0,0 +1,958 @@
|
||||
//! 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
|
||||
&& let Some(SharedBody::Interface { items }) =
|
||||
context.element(interface).and_then(Element::shared)
|
||||
{
|
||||
for item in items {
|
||||
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.elements.get_mut(&interface).unwrap().body = ElementBody::shared_in(
|
||||
Layer::Sa,
|
||||
SharedBody::Interface {
|
||||
items: vec![first, 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.elements.get_mut(&interface).unwrap().body =
|
||||
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![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."]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user