Projects: classes as entities, and the sidebar fits short windows
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A project is a class with facts (description, status, kind, party, dates, links, notes) in one project.set event behind the format feature projects. Four tools (books.add_project, update_project, projects, project); documents and decisions link to projects; B-37: on-spec costs are expensed as incurred, repayment is revenue, and the unrecovered cost is never below zero (Kani). Viewer: a Projects list and a project page with its figures, by month and by account. The sidebar measures its footer instead of reserving a fixed 172 px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
71 files changed
+7446
-217
No files matched your search
@@ -59,6 +59,7 @@ pub const KNOWN_FEATURES: &[&str] = &[
|
||||
TAX_CATEGORIES_GAIN_EXEMPT,
|
||||
DISPOSAL_BEFORE_SERVICE,
|
||||
DEPRECIATION_CATCH_UP,
|
||||
PROJECTS,
|
||||
];
|
||||
|
||||
/// Feature: `period.closed` closes to members with `"rounding":
|
||||
@@ -147,6 +148,13 @@ pub const SCHEDULE_CONVENTION: &str = "schedule_convention";
|
||||
/// with the value at contribution and the §704(c) method (P3, defect 13).
|
||||
pub const ASSET_BASIS: &str = "asset_basis";
|
||||
|
||||
/// Feature: projects (rules.md B-37): `project.set`, a new event type (a
|
||||
/// class's project facts); `attachment.linked` to the subject kind
|
||||
/// `project` (an older version would call it corrupt); and
|
||||
/// `decision.recorded` with `projects` (an older version would drop them
|
||||
/// silently).
|
||||
pub const PROJECTS: &str = "projects";
|
||||
|
||||
/// The line type that declares the journal format.
|
||||
pub const FORMAT_SET: &str = "journal.format_set";
|
||||
|
||||
@@ -209,6 +217,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"payroll.taxes_remitted",
|
||||
"period.closed",
|
||||
"period.created",
|
||||
"project.set",
|
||||
"receipt.attached",
|
||||
"receipt.linked",
|
||||
"receipt.unlinked",
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::error::StoreError;
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::conventions::{Approval, ConventionKind, NewConvention, NewDecision, NewOpenItem};
|
||||
use ledger_core::settings::{Change, Source};
|
||||
use ledger_core::{ClassId, ProjectFacts, ProjectKind, ProjectStatus};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// The journal format these events need: an older reader stops at the
|
||||
@@ -170,7 +171,9 @@ pub fn convention_retired_of(p: &Value) -> Result<(String, String, Source), Stor
|
||||
}
|
||||
|
||||
/// `decision.recorded {id, subject, decision_md, decided_by, decided_on,
|
||||
/// evidence_attachment?, supersedes?, citation?}`.
|
||||
/// evidence_attachment?, supersedes?, citation?, projects?}`: `projects`
|
||||
/// only when it names any (journal feature `projects`), so a decision
|
||||
/// without them writes the bytes it always did.
|
||||
pub fn decision_recorded(id: u64, d: &NewDecision) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("id".into(), json!(id));
|
||||
@@ -181,6 +184,9 @@ pub fn decision_recorded(id: u64, d: &NewDecision) -> Value {
|
||||
put(&mut o, "evidence_attachment", d.evidence_attachment);
|
||||
put(&mut o, "supersedes", d.supersedes);
|
||||
put(&mut o, "citation", d.citation.clone());
|
||||
if !d.projects.is_empty() {
|
||||
o.insert("projects".into(), json!(d.projects));
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
@@ -196,6 +202,17 @@ pub fn decision_recorded_of(p: &Value) -> Result<(u64, NewDecision), StoreError>
|
||||
.collect::<Option<Vec<_>>>()
|
||||
})
|
||||
.ok_or_else(|| corrupt(E, "decided_by"))?;
|
||||
let projects = match p.get("projects") {
|
||||
None | Some(Value::Null) => vec![],
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.and_then(|a| {
|
||||
a.iter()
|
||||
.map(|x| x.as_u64().and_then(|n| ClassId::try_from(n).ok()))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
})
|
||||
.ok_or_else(|| corrupt(E, "projects"))?,
|
||||
};
|
||||
Ok((
|
||||
u64_of(p, E, "id")?,
|
||||
NewDecision {
|
||||
@@ -206,6 +223,74 @@ pub fn decision_recorded_of(p: &Value) -> Result<(u64, NewDecision), StoreError>
|
||||
evidence_attachment: opt_u64(p, E, "evidence_attachment")?,
|
||||
supersedes: opt_u64(p, E, "supersedes")?,
|
||||
citation: opt_str(p, E, "citation")?,
|
||||
projects,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// `project.set {id, name, status, kind?, description?, party_id?,
|
||||
/// started_on?, ended_on?, links?, notes?}`: a class's whole project
|
||||
/// record (rules.md B-37; journal feature `projects`). Every optional key
|
||||
/// is left out when empty, so the payload is canonical.
|
||||
pub fn project_set(id: ClassId, name: &str, f: &ProjectFacts) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("id".into(), json!(id));
|
||||
o.insert("name".into(), json!(name));
|
||||
o.insert("status".into(), json!(f.status.key()));
|
||||
put(&mut o, "kind", f.kind.map(ProjectKind::key));
|
||||
put(&mut o, "description", f.description.clone());
|
||||
put(&mut o, "party_id", f.party);
|
||||
put(&mut o, "started_on", f.started_on.map(|d| d.to_string()));
|
||||
put(&mut o, "ended_on", f.ended_on.map(|d| d.to_string()));
|
||||
if !f.links.is_empty() {
|
||||
o.insert("links".into(), json!(f.links));
|
||||
}
|
||||
put(&mut o, "notes", f.notes.clone());
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read a `project.set`: `(id, name, facts)`. A missing or mistyped key is
|
||||
/// corrupt; a status or kind this version doesn't know is newer (a newer
|
||||
/// version wrote it, rules.md B-27).
|
||||
pub fn project_set_of(
|
||||
line: &crate::journal::JournalLine,
|
||||
) -> Result<(ClassId, String, ProjectFacts), StoreError> {
|
||||
const E: &str = "project.set";
|
||||
let p = &line.payload;
|
||||
let id = ClassId::try_from(u64_of(p, E, "id")?).map_err(|_| corrupt(E, "id"))?;
|
||||
let name = str_of(p, E, "name")?;
|
||||
let key = str_of(p, E, "status")?;
|
||||
let status = ProjectStatus::parse(&key)
|
||||
.ok_or_else(|| crate::format::newer_content(line, format!("the project status {key:?}")))?;
|
||||
let kind = match opt_str(p, E, "kind")? {
|
||||
None => None,
|
||||
Some(k) => Some(ProjectKind::parse(&k).ok_or_else(|| {
|
||||
crate::format::newer_content(line, format!("the project kind {k:?}"))
|
||||
})?),
|
||||
};
|
||||
let links = match p.get("links") {
|
||||
None | Some(Value::Null) => vec![],
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.and_then(|a| {
|
||||
a.iter()
|
||||
.map(|x| x.as_str().map(str::to_string))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
})
|
||||
.ok_or_else(|| corrupt(E, "links"))?,
|
||||
};
|
||||
Ok((
|
||||
id,
|
||||
name,
|
||||
ProjectFacts {
|
||||
description: opt_str(p, E, "description")?,
|
||||
status,
|
||||
kind,
|
||||
party: opt_u64(p, E, "party_id")?,
|
||||
started_on: opt_date(p, E, "started_on")?,
|
||||
ended_on: opt_date(p, E, "ended_on")?,
|
||||
links,
|
||||
notes: opt_str(p, E, "notes")?,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -328,6 +413,7 @@ mod tests {
|
||||
evidence_attachment: Some(7),
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
projects: vec![],
|
||||
};
|
||||
assert_eq!(
|
||||
decision_recorded_of(&decision_recorded(3, &d)).unwrap(),
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn report_columns(
|
||||
#[serde(tag = "show", rename_all = "snake_case")]
|
||||
pub enum ViewerTarget {
|
||||
/// A top-level page: overview, cash, profit, transactions, reports,
|
||||
/// accounts, assets, parties, documents, company, journal.
|
||||
/// accounts, assets, parties, projects, documents, company, journal.
|
||||
Page {
|
||||
/// Page name.
|
||||
page: String,
|
||||
@@ -120,6 +120,12 @@ pub enum ViewerTarget {
|
||||
/// Party id.
|
||||
party_id: u64,
|
||||
},
|
||||
/// One project (a class with its facts): its figures, entries,
|
||||
/// documents and decisions.
|
||||
Project {
|
||||
/// Class id.
|
||||
class_id: u32,
|
||||
},
|
||||
/// One reconciliation: its account, with the account's statements and
|
||||
/// reconciliations.
|
||||
Reconciliation {
|
||||
|
||||
@@ -950,6 +950,9 @@ impl Store {
|
||||
return Err(StoreError::AttachmentWithdrawn(a));
|
||||
}
|
||||
self.ensure_format()?;
|
||||
if !d.projects.is_empty() {
|
||||
self.ensure_feature(crate::format::PROJECTS)?;
|
||||
}
|
||||
let line =
|
||||
self.append_journal("decision.recorded", crate::meta::decision_recorded(id, &d))?;
|
||||
self.ledger.apply_decision(id, d, line.seq)?;
|
||||
@@ -3365,15 +3368,19 @@ impl Store {
|
||||
// Classes
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Add a class (division) for line-level P&L tracking. Journaled as
|
||||
/// `class.added`.
|
||||
/// Add a class (division) for line-level P&L tracking: a project with
|
||||
/// only its name. Journaled as `class.added`.
|
||||
pub fn add_class(&mut self, name: &str) -> Result<ClassId, StoreError> {
|
||||
// Rehearsed on a copy: the name rules (blank, length, a name
|
||||
// another class has) refuse here, so the journal write can't be
|
||||
// followed by a failed mutation.
|
||||
self.rehearse(|l| l.add_class(name))?;
|
||||
// The engine's only failure mode is an empty name; replicate it so
|
||||
// the journal write cannot be followed by a failed mutation.
|
||||
if name.trim().is_empty() {
|
||||
return Err(LedgerError::EmptyClassName.into());
|
||||
}
|
||||
self.append_class(name)
|
||||
}
|
||||
|
||||
/// `class.added` for a name already rehearsed.
|
||||
fn append_class(&mut self, name: &str) -> Result<ClassId, StoreError> {
|
||||
let name = name.trim();
|
||||
let id = self.ledger.next_class_id();
|
||||
let line = self.append_journal("class.added", json!({ "id": id, "name": name }))?;
|
||||
let live = self.ledger.add_class(name)?;
|
||||
@@ -3382,6 +3389,83 @@ impl Store {
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// Add a project (rules.md B-37): `class.added`, then `project.set`
|
||||
/// when `facts` say more than the defaults. Returns its id.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a project in one command, journaled as two events only when
|
||||
/// it has facts, so a name-only project is a plain class.
|
||||
/// SOURCE: docs/spec/rules.md B-37 item 1; B-27 (journal feature
|
||||
/// `projects`).
|
||||
/// INVARIANTS:
|
||||
/// 1. A refusal journals nothing: the whole record is rehearsed, and
|
||||
/// the feature asked for, before the first line.
|
||||
/// 2. Facts equal to the defaults journal `class.added` alone (books
|
||||
/// waiting for their format upgrade can still add one).
|
||||
/// ```
|
||||
pub fn add_project(
|
||||
&mut self,
|
||||
name: &str,
|
||||
facts: &ledger_core::ProjectFacts,
|
||||
) -> Result<ClassId, StoreError> {
|
||||
self.rehearse(|l| {
|
||||
let id = l.add_class(name)?;
|
||||
if !facts.is_default() {
|
||||
l.set_project(id, name, facts.clone())?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let with_facts = !facts.is_default();
|
||||
if with_facts {
|
||||
self.ensure_feature(crate::format::PROJECTS)?;
|
||||
}
|
||||
let id = self.append_class(name)?;
|
||||
if with_facts {
|
||||
let (name, facts) = self.ledger.check_set_project(id, name, facts)?;
|
||||
self.append_project(id, name, facts)?;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Set a project's whole record (`project.set`): its name and facts.
|
||||
/// Unchanged (the same name and facts once checked): journals nothing
|
||||
/// and returns false.
|
||||
pub fn set_project(
|
||||
&mut self,
|
||||
id: ClassId,
|
||||
name: &str,
|
||||
facts: &ledger_core::ProjectFacts,
|
||||
) -> Result<bool, StoreError> {
|
||||
let (name, facts) = self.ledger.check_set_project(id, name, facts)?;
|
||||
let same_name = self.ledger.get_class(id).is_some_and(|c| c.name == name);
|
||||
if same_name && self.ledger.project_facts(id).as_ref() == Some(&facts) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.ensure_feature(crate::format::PROJECTS)?;
|
||||
self.append_project(id, name, facts)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// `project.set` for a record already checked.
|
||||
fn append_project(
|
||||
&mut self,
|
||||
id: ClassId,
|
||||
name: String,
|
||||
facts: ledger_core::ProjectFacts,
|
||||
) -> Result<(), StoreError> {
|
||||
let line =
|
||||
self.append_journal("project.set", crate::meta::project_set(id, &name, &facts))?;
|
||||
self.ledger.set_project(id, &name, facts)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A class's project facts (the defaults for a class with none);
|
||||
/// `None` for no such class.
|
||||
pub fn project_facts(&self, id: ClassId) -> Option<ledger_core::ProjectFacts> {
|
||||
self.ledger.project_facts(id)
|
||||
}
|
||||
|
||||
/// All classes, in id order.
|
||||
pub fn list_classes(&self) -> Vec<Class> {
|
||||
self.ledger.list_classes().into_iter().cloned().collect()
|
||||
@@ -4157,6 +4241,17 @@ impl Store {
|
||||
// Attachment links, statements, reconciliations
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// The journal feature a link to `subject` needs, if any: a subject
|
||||
/// kind an older version doesn't know (a project, rules.md B-37).
|
||||
/// Callers filing a file with links ask it before filing, so a refusal
|
||||
/// leaves nothing half-filed.
|
||||
pub fn subject_feature(subject: Subject) -> Option<&'static str> {
|
||||
match subject {
|
||||
Subject::Project(_) => Some(crate::format::PROJECTS),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `subject` exists in the books (what [`Store::link_attachment`]
|
||||
/// checks, so callers can validate several subjects before linking any).
|
||||
pub fn subject_exists(&self, subject: Subject) -> Result<bool, StoreError> {
|
||||
@@ -4166,6 +4261,7 @@ impl Store {
|
||||
Subject::Account(i) => l.chart().get(i).is_some(),
|
||||
Subject::Asset(i) => l.get_asset(i).is_some(),
|
||||
Subject::Party(i) => l.get_party(i).is_some(),
|
||||
Subject::Project(i) => u32::try_from(i).ok().and_then(|c| l.get_class(c)).is_some(),
|
||||
Subject::Invoice(i) => l.get_invoice(i).is_some(),
|
||||
Subject::Bill(i) => l.get_bill(i).is_some(),
|
||||
Subject::Period(i) => l.list_periods().iter().any(|p| p.id == i),
|
||||
@@ -4227,8 +4323,8 @@ impl Store {
|
||||
}
|
||||
|
||||
/// Link a stored attachment to a subject: another entry, an account, an
|
||||
/// asset, a party, an invoice, a bill, a period, a payroll run, a
|
||||
/// schedule, a reconciliation, or the company.
|
||||
/// asset, a party, a project, an invoice, a bill, a period, a payroll
|
||||
/// run, a schedule, a reconciliation, or the company.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: one file, linked wherever it's evidence, without refiling.
|
||||
@@ -4262,6 +4358,9 @@ impl Store {
|
||||
{
|
||||
return Err(StoreError::AlreadyLinked(attachment_id, subject.describe()));
|
||||
}
|
||||
if let Some(f) = Self::subject_feature(subject) {
|
||||
self.ensure_feature(f)?;
|
||||
}
|
||||
let line = self.append_journal(
|
||||
"attachment.linked",
|
||||
json!({"attachment_id": attachment_id, "subject": subject}),
|
||||
@@ -6105,6 +6204,10 @@ fn replay_line(
|
||||
"class.added" => {
|
||||
ledger.replay_class(get_u64(p, "id")? as u32, get_str(p, "name")?.to_string())?;
|
||||
}
|
||||
"project.set" => {
|
||||
let (id, name, facts) = crate::meta::project_set_of(line)?;
|
||||
ledger.replay_set_project(id, name, facts)?;
|
||||
}
|
||||
"entry.line_class_set" => {
|
||||
// Journaled while the entry was a draft, so at replay time the
|
||||
// draft exists and takes the class. Missing/null class_id
|
||||
@@ -7140,6 +7243,14 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
params![get_u64(p, "id")? as i64, get_str(p, "name")?],
|
||||
)?;
|
||||
}
|
||||
"project.set" => {
|
||||
// Facts live in the ledger (as parties'); the projection keeps
|
||||
// the name, so verify's class check stays exact after a rename.
|
||||
conn.execute(
|
||||
"UPDATE classes SET name = ? WHERE id = ?",
|
||||
params![get_str(p, "name")?, get_u64(p, "id")? as i64],
|
||||
)?;
|
||||
}
|
||||
"entry.line_class_set" => {
|
||||
// Journaled while the entry is a draft, so the projection
|
||||
// usually has no lines row yet (drafts never touch the
|
||||
@@ -7440,6 +7551,9 @@ pub enum Subject {
|
||||
Schedule(u64),
|
||||
/// A reconciliation (its statement).
|
||||
Reconciliation(u64),
|
||||
/// A project (its contract, spec, pitch, correspondence; rules.md
|
||||
/// B-37). Journal feature `projects`.
|
||||
Project(u64),
|
||||
/// The company itself (formation documents).
|
||||
Company,
|
||||
}
|
||||
@@ -7458,6 +7572,7 @@ impl Subject {
|
||||
Subject::PayrollRun(_) => "payroll_run",
|
||||
Subject::Schedule(_) => "schedule",
|
||||
Subject::Reconciliation(_) => "reconciliation",
|
||||
Subject::Project(_) => "project",
|
||||
Subject::Company => "company",
|
||||
}
|
||||
}
|
||||
@@ -7474,7 +7589,8 @@ impl Subject {
|
||||
| Subject::Period(i)
|
||||
| Subject::PayrollRun(i)
|
||||
| Subject::Schedule(i)
|
||||
| Subject::Reconciliation(i) => i,
|
||||
| Subject::Reconciliation(i)
|
||||
| Subject::Project(i) => i,
|
||||
Subject::Company => 0,
|
||||
}
|
||||
}
|
||||
@@ -7492,6 +7608,7 @@ impl Subject {
|
||||
"payroll_run" => Subject::PayrollRun(id),
|
||||
"schedule" => Subject::Schedule(id),
|
||||
"reconciliation" => Subject::Reconciliation(id),
|
||||
"project" => Subject::Project(id),
|
||||
"company" => Subject::Company,
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -897,7 +897,7 @@ fn read_journal_and_format_gives_every_line_and_the_format() {
|
||||
/// declaration as newer, never at the line, and never calls it corrupt.
|
||||
#[test]
|
||||
fn p3_features_wait_for_the_upgrade_and_are_declared_once() {
|
||||
for f in ["account_renamed", "require_tax_category"] {
|
||||
for f in ["account_renamed", "require_tax_category", "projects"] {
|
||||
assert!(KNOWN_FEATURES.contains(&f), "{f}");
|
||||
}
|
||||
let dir = test_dir("p3-features");
|
||||
@@ -927,11 +927,20 @@ fn p3_features_wait_for_the_upgrade_and_are_declared_once() {
|
||||
store.change_settings(&[policy()], &source).map(|_| ()),
|
||||
"the journal feature require_tax_category",
|
||||
);
|
||||
let facts = ledger_core::ProjectFacts {
|
||||
kind: Some(ledger_core::ProjectKind::OnSpec),
|
||||
..Default::default()
|
||||
};
|
||||
waits(
|
||||
store.add_project("Harbor", &facts).map(|_| ()),
|
||||
"the journal feature projects",
|
||||
);
|
||||
assert_eq!(store.head(), head, "refused whole");
|
||||
assert!(store.upgrade_format().unwrap().is_some());
|
||||
store.rename_account(1, "Business Checking", None).unwrap();
|
||||
store.rename_account(1, "Checking", None).unwrap();
|
||||
store.change_settings(&[policy()], &source).unwrap();
|
||||
store.add_project("Harbor", &facts).unwrap();
|
||||
assert_eq!(*store.journal_format(), ledger_store::format::current());
|
||||
drop(store);
|
||||
let text = std::fs::read_to_string(dir.join(JOURNAL_FILE)).unwrap();
|
||||
@@ -942,7 +951,12 @@ fn p3_features_wait_for_the_upgrade_and_are_declared_once() {
|
||||
let kinds: Vec<(&str, serde_json::Value)> = lines
|
||||
.iter()
|
||||
.map(|l| (l["type"].as_str().unwrap(), l["payload"].clone()))
|
||||
.filter(|(t, _)| matches!(*t, FORMAT_SET | "account.renamed" | "settings.set"))
|
||||
.filter(|(t, _)| {
|
||||
matches!(
|
||||
*t,
|
||||
FORMAT_SET | "account.renamed" | "settings.set" | "project.set"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let current = ledger_store::format::current();
|
||||
assert_eq!(kinds[0].0, FORMAT_SET);
|
||||
@@ -953,7 +967,8 @@ fn p3_features_wait_for_the_upgrade_and_are_declared_once() {
|
||||
assert_eq!(kinds[1].0, "account.renamed");
|
||||
assert_eq!(kinds[2].0, "account.renamed");
|
||||
assert_eq!(kinds[3].0, "settings.set");
|
||||
assert_eq!(kinds.len(), 4);
|
||||
assert_eq!(kinds[4].0, "project.set");
|
||||
assert_eq!(kinds.len(), 5);
|
||||
// An earlier reader knows no feature (P2: KNOWN_FEATURES was empty),
|
||||
// so the gate stops it at the declaration, before the rename: the
|
||||
// gate's refusal of a feature it doesn't know is pinned by
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
//! Projects in the journal (rules.md B-37): `class.added`, then the whole
|
||||
//! record as `project.set` (journal feature `projects`); links to a
|
||||
//! project and decisions naming one wait for the feature too.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a project's record is journaled once, whole, replays exactly,
|
||||
//! and books waiting for their format upgrade can still add
|
||||
//! projects by name but write nothing an older reader would misread.
|
||||
//! SOURCE: docs/spec/rules.md B-37, B-27; D-39 (the owner's upgrade).
|
||||
//! ```
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::conventions::NewDecision;
|
||||
use ledger_core::{Currency, ProjectFacts, ProjectKind, ProjectStatus};
|
||||
use ledger_store::{
|
||||
JOURNAL_FILE, JournalFormat, JournalWriter, Snapshot, Store, StoreError, Subject,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"wanderbooks-projects-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn facts() -> ProjectFacts {
|
||||
ProjectFacts {
|
||||
description: Some("An app for the harbor".into()),
|
||||
kind: Some(ProjectKind::OnSpec),
|
||||
started_on: Some(d(2026, 8, 1)),
|
||||
links: vec!["https://harbor.example/app".into()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every journal line, as (type, payload).
|
||||
fn lines(dir: &Path) -> Vec<(String, Value)> {
|
||||
std::fs::read_to_string(dir.join(JOURNAL_FILE))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|l| {
|
||||
let v: Value = serde_json::from_str(l).unwrap();
|
||||
(
|
||||
v["type"].as_str().unwrap().to_string(),
|
||||
v["payload"].clone(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decision(projects: Vec<u32>) -> NewDecision {
|
||||
NewDecision {
|
||||
subject: "Harbor".into(),
|
||||
decision_md: "Build it on spec.".into(),
|
||||
decided_by: vec!["Tobin Harlow".into()],
|
||||
decided_on: d(2026, 8, 1),
|
||||
evidence_attachment: None,
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
projects,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_project_journals_class_added_then_project_set() {
|
||||
let dir = test_dir("add");
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
let id = s.add_project(" Harbor app ", &facts()).unwrap();
|
||||
let l = lines(&dir.0);
|
||||
let n = l.len();
|
||||
assert_eq!(
|
||||
l[n - 2],
|
||||
(
|
||||
"class.added".into(),
|
||||
json!({"id": id, "name": "Harbor app"})
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
l[n - 1],
|
||||
(
|
||||
"project.set".into(),
|
||||
json!({"id": id, "name": "Harbor app", "status": "active", "kind": "on_spec",
|
||||
"description": "An app for the harbor", "started_on": "2026-08-01",
|
||||
"links": ["https://harbor.example/app"]})
|
||||
)
|
||||
);
|
||||
assert_eq!(s.project_facts(id), Some(facts()));
|
||||
// A refused add journals nothing: a taken name.
|
||||
let head = s.head();
|
||||
assert!(s.add_project("HARBOR APP", &facts()).is_err());
|
||||
assert!(
|
||||
s.add_project(
|
||||
"Dock",
|
||||
&ProjectFacts {
|
||||
ended_on: Some(d(2026, 1, 1)),
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(s.head(), head);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_only_project_journals_class_added_alone() {
|
||||
let dir = test_dir("name-only");
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
let before = lines(&dir.0).len();
|
||||
let id = s.add_project("Harbor", &ProjectFacts::default()).unwrap();
|
||||
let l = lines(&dir.0);
|
||||
assert_eq!(l.len(), before + 1);
|
||||
assert_eq!(l[before].0, "class.added");
|
||||
assert_eq!(s.project_facts(id), Some(ProjectFacts::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_writes_wait_for_the_upgrade_and_journal_nothing() {
|
||||
let dir = test_dir("upgrade");
|
||||
let mut s = Store::create_in_format(&dir.0, Currency::USD, &JournalFormat::default()).unwrap();
|
||||
// A name alone is a plain class: accepted.
|
||||
let id = s.add_project("Harbor", &ProjectFacts::default()).unwrap();
|
||||
let (att, _, _) = s
|
||||
.file_document("other", "pitch.pdf", "application/pdf", b"%PDF pitch", None)
|
||||
.unwrap();
|
||||
let head = s.head();
|
||||
let waits = |r: Result<(), StoreError>| match r {
|
||||
Err(StoreError::FormatUpgradePending { need }) => need,
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
};
|
||||
let projects = "the journal feature projects";
|
||||
assert_eq!(waits(s.add_project("Dock", &facts()).map(|_| ())), projects);
|
||||
assert_eq!(
|
||||
waits(s.set_project(id, "Harbor", &facts()).map(|_| ())),
|
||||
projects
|
||||
);
|
||||
assert_eq!(
|
||||
waits(s.link_attachment(att, Subject::Project(u64::from(id)))),
|
||||
projects
|
||||
);
|
||||
// A decision waits for format 2 itself on these books; with
|
||||
// projects it waits for that feature too.
|
||||
waits(s.record_decision(&decision(vec![id])).map(|_| ()));
|
||||
assert_eq!(s.head(), head, "nothing journaled");
|
||||
assert!(s.upgrade_format().unwrap().is_some());
|
||||
s.add_project("Dock", &facts()).unwrap();
|
||||
assert!(s.set_project(id, "Harbor", &facts()).unwrap());
|
||||
s.link_attachment(att, Subject::Project(u64::from(id)))
|
||||
.unwrap();
|
||||
s.record_decision(&decision(vec![id])).unwrap();
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// The live books after this version is installed: format 2 with every
|
||||
/// earlier feature but not `projects`. The format check passes; only the
|
||||
/// feature check refuses, and nothing is written.
|
||||
#[test]
|
||||
fn format_2_books_without_the_feature_wait_for_it_alone() {
|
||||
let dir = test_dir("no-feature");
|
||||
let mut format = ledger_store::format::current();
|
||||
format
|
||||
.features
|
||||
.retain(|f| f != ledger_store::format::PROJECTS);
|
||||
assert_eq!(format.format, 2);
|
||||
let mut s = Store::create_in_format(&dir.0, Currency::USD, &format).unwrap();
|
||||
let id = s.add_project("Harbor", &ProjectFacts::default()).unwrap();
|
||||
let (att, _, _) = s
|
||||
.file_document("other", "pitch.pdf", "application/pdf", b"%PDF pitch", None)
|
||||
.unwrap();
|
||||
// A decision naming no project is format 2's: accepted.
|
||||
s.record_decision(&decision(vec![])).unwrap();
|
||||
let head = s.head();
|
||||
let waits = |r: Result<(), StoreError>| match r {
|
||||
Err(StoreError::FormatUpgradePending { need }) => need,
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
};
|
||||
let projects = "the journal feature projects";
|
||||
assert_eq!(waits(s.add_project("Dock", &facts()).map(|_| ())), projects);
|
||||
assert_eq!(
|
||||
waits(s.set_project(id, "Harbor", &facts()).map(|_| ())),
|
||||
projects
|
||||
);
|
||||
assert_eq!(
|
||||
waits(s.link_attachment(att, Subject::Project(u64::from(id)))),
|
||||
projects
|
||||
);
|
||||
assert_eq!(
|
||||
waits(s.record_decision(&decision(vec![id])).map(|_| ())),
|
||||
projects
|
||||
);
|
||||
assert_eq!(s.head(), head, "nothing journaled");
|
||||
// A name alone is still a plain class.
|
||||
s.add_project("Dock", &ProjectFacts::default()).unwrap();
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// `class.added` lines appended as an earlier version wrote them: no
|
||||
/// uniqueness and no trimming.
|
||||
fn old_classes(dir: &Path, names: &[&str]) {
|
||||
let s = Store::create(dir, Currency::USD).unwrap();
|
||||
drop(s);
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
w.append("class.added", json!({"id": i + 1, "name": n}))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_books_with_a_duplicate_name_change_either_project() {
|
||||
let dir = test_dir("dup");
|
||||
old_classes(&dir.0, &["Alpha", "alpha", "Harbor"]);
|
||||
let mut s = Store::open(&dir.0).unwrap();
|
||||
let paused = ProjectFacts {
|
||||
status: ProjectStatus::Paused,
|
||||
..Default::default()
|
||||
};
|
||||
// A fact-only change to each keeps its name.
|
||||
assert!(s.set_project(1, "Alpha", &paused).unwrap());
|
||||
assert!(s.set_project(2, "alpha", &paused).unwrap());
|
||||
// Its own name up to capitals and space is still its own.
|
||||
assert!(s.set_project(2, " ALPHA ", &paused).unwrap());
|
||||
assert_eq!(s.list_classes()[1].name, "ALPHA");
|
||||
// A rename onto another project's name is still refused.
|
||||
let head = s.head();
|
||||
assert!(matches!(
|
||||
s.set_project(1, "harbor", &paused),
|
||||
Err(StoreError::Ledger(
|
||||
ledger_core::LedgerError::DuplicateClassName(_)
|
||||
))
|
||||
));
|
||||
assert_eq!(s.head(), head);
|
||||
// A rename away from the duplicate is accepted.
|
||||
assert!(s.set_project(2, "Alpha two", &paused).unwrap());
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fact_only_change_never_renames_an_untrimmed_old_name() {
|
||||
let dir = test_dir("untrimmed");
|
||||
old_classes(&dir.0, &["Retail "]);
|
||||
let mut s = Store::open(&dir.0).unwrap();
|
||||
let head = s.head();
|
||||
// Its own name, exactly: unchanged, nothing journaled.
|
||||
assert!(
|
||||
!s.set_project(1, "Retail ", &ProjectFacts::default())
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(s.head(), head);
|
||||
let paused = ProjectFacts {
|
||||
status: ProjectStatus::Paused,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(s.set_project(1, "Retail ", &paused).unwrap());
|
||||
let l = lines(&dir.0);
|
||||
assert_eq!(
|
||||
l.last().unwrap().1["name"],
|
||||
"Retail ",
|
||||
"no rename journaled"
|
||||
);
|
||||
assert_eq!(s.list_classes()[0].name, "Retail ");
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_with_nothing_new_journals_nothing() {
|
||||
let dir = test_dir("same");
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
let id = s.add_project("Harbor", &facts()).unwrap();
|
||||
let head = s.head();
|
||||
assert!(!s.set_project(id, " Harbor ", &facts()).unwrap());
|
||||
assert_eq!(s.head(), head);
|
||||
let mut f = facts();
|
||||
f.status = ProjectStatus::Paused;
|
||||
assert!(s.set_project(id, "Harbor", &f).unwrap());
|
||||
assert_ne!(s.head(), head);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_set_replays_verifies_and_reloads() {
|
||||
let dir = test_dir("replay");
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
let party = s.add_party("Harbor Co", &[]).unwrap();
|
||||
let id = s.add_project("Harbor", &facts()).unwrap();
|
||||
let mut f = facts();
|
||||
f.party = Some(party);
|
||||
f.status = ProjectStatus::Finished;
|
||||
f.ended_on = Some(d(2026, 9, 30));
|
||||
s.set_project(id, "Harbor app", &f).unwrap();
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
let s = Store::open(&dir.0).unwrap();
|
||||
s.verify().unwrap();
|
||||
assert_eq!(s.project_facts(id), Some(f.clone()));
|
||||
assert_eq!(s.list_classes()[0].name, "Harbor app");
|
||||
drop(s);
|
||||
let snap = Snapshot::load(&dir.0).unwrap();
|
||||
assert_eq!(snap.ledger().project_facts(id), Some(f));
|
||||
assert_eq!(snap.ledger().projects_of_party(party), vec![id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_status_or_kind_is_newer_a_malformed_record_corrupt() {
|
||||
for (case, payload, newer) in [
|
||||
(
|
||||
"status",
|
||||
json!({"id": 1, "name": "Harbor", "status": "on_hold"}),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"kind",
|
||||
json!({"id": 1, "name": "Harbor", "status": "active", "kind": "grant"}),
|
||||
true,
|
||||
),
|
||||
("no-status", json!({"id": 1, "name": "Harbor"}), false),
|
||||
(
|
||||
"links",
|
||||
json!({"id": 1, "name": "Harbor", "status": "active", "links": "x"}),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"date",
|
||||
json!({"id": 1, "name": "Harbor", "status": "active", "started_on": "soon"}),
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let dir = test_dir(&format!("bad-{case}"));
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
s.add_class("Harbor").unwrap();
|
||||
drop(s);
|
||||
let mut w = JournalWriter::open(&dir.0.join(JOURNAL_FILE)).unwrap();
|
||||
w.append("project.set", payload).unwrap();
|
||||
drop(w);
|
||||
match Store::open(&dir.0) {
|
||||
Err(StoreError::NewerJournal { .. }) if newer => {}
|
||||
Err(StoreError::JournalCorrupt(_)) if !newer => {}
|
||||
Err(e) => panic!("{case}: {e:?}"),
|
||||
Ok(_) => panic!("{case}: opened"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_decision_without_projects_writes_the_bytes_it_always_did() {
|
||||
let d0 = decision(vec![]);
|
||||
let v = ledger_store::meta::decision_recorded(1, &d0);
|
||||
assert!(v.get("projects").is_none());
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({"id": 1, "subject": "Harbor", "decision_md": "Build it on spec.",
|
||||
"decided_by": ["Tobin Harlow"], "decided_on": "2026-08-01"})
|
||||
);
|
||||
let (_, back) = ledger_store::meta::decision_recorded_of(&v).unwrap();
|
||||
assert_eq!(back, d0);
|
||||
let with = ledger_store::meta::decision_recorded(1, &decision(vec![3, 1]));
|
||||
assert_eq!(with["projects"], json!([3, 1]));
|
||||
assert_eq!(
|
||||
ledger_store::meta::decision_recorded_of(&with)
|
||||
.unwrap()
|
||||
.1
|
||||
.projects,
|
||||
vec![3, 1]
|
||||
);
|
||||
let mut bad = with.clone();
|
||||
bad["projects"] = json!(["x"]);
|
||||
assert!(ledger_store::meta::decision_recorded_of(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachments_link_to_a_project_and_unlink() {
|
||||
let dir = test_dir("links");
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
let id = s.add_project("Harbor", &ProjectFacts::default()).unwrap();
|
||||
let (att, _, _) = s
|
||||
.file_document(
|
||||
"contract",
|
||||
"spec.pdf",
|
||||
"application/pdf",
|
||||
b"%PDF spec",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let sub = Subject::Project(u64::from(id));
|
||||
assert_eq!(Store::subject_feature(sub), Some("projects"));
|
||||
assert_eq!(Store::subject_feature(Subject::Party(1)), None);
|
||||
assert!(s.subject_exists(sub).unwrap());
|
||||
assert!(!s.subject_exists(Subject::Project(99)).unwrap());
|
||||
assert!(!s.subject_exists(Subject::Project(u64::MAX)).unwrap());
|
||||
assert!(s.link_attachment(att, Subject::Project(99)).is_err());
|
||||
s.link_attachment(att, sub).unwrap();
|
||||
assert_eq!(s.attachments_for(sub).unwrap()[0].id, att);
|
||||
assert!(s.attachment_links(att).unwrap().contains(&sub));
|
||||
drop(s);
|
||||
let mut s = Store::open(&dir.0).unwrap();
|
||||
s.verify().unwrap();
|
||||
s.unlink_attachment(att, sub, "wrong project").unwrap();
|
||||
assert!(s.attachments_for(sub).unwrap().is_empty());
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_books_open_to_their_figures_with_projects_known() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books");
|
||||
let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
assert!(!dirs.is_empty());
|
||||
for fixture in dirs {
|
||||
let name = fixture.file_name().unwrap().to_string_lossy().to_string();
|
||||
let dir = test_dir(&format!("old-{name}"));
|
||||
copy(&fixture, &dir.0);
|
||||
let journal = std::fs::read(dir.0.join(JOURNAL_FILE)).unwrap();
|
||||
let expected: Value =
|
||||
serde_json::from_slice(&std::fs::read(fixture.join("expected.json")).unwrap()).unwrap();
|
||||
let s = Store::open(&dir.0).unwrap_or_else(|e| panic!("{name}: refused: {e}"));
|
||||
// The figures that version computed, unchanged by knowing projects.
|
||||
assert_eq!(
|
||||
json!(ledger_core::reports::snapshot(s.ledger()).map_err(|e| e.to_string())),
|
||||
expected["snapshot"],
|
||||
"{name}: figures"
|
||||
);
|
||||
assert_eq!(
|
||||
json!(s.verify().map(|_| ()).map_err(|e| e.to_string())),
|
||||
expected["verify"],
|
||||
"{name}: verify"
|
||||
);
|
||||
for c in s.list_classes() {
|
||||
assert_eq!(
|
||||
s.project_facts(c.id),
|
||||
Some(ProjectFacts::default()),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!lines(&dir.0).iter().any(|(t, _)| t == "project.set"),
|
||||
"{name}"
|
||||
);
|
||||
drop(s);
|
||||
assert_eq!(
|
||||
std::fs::read(dir.0.join(JOURNAL_FILE)).unwrap(),
|
||||
journal,
|
||||
"{name}: opening journals nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn copy(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for e in std::fs::read_dir(from).unwrap() {
|
||||
let e = e.unwrap();
|
||||
if e.file_name() == "expected.json" || e.file_name() == "record.json" {
|
||||
continue;
|
||||
}
|
||||
if e.file_type().unwrap().is_dir() {
|
||||
copy(&e.path(), &to.join(e.file_name()));
|
||||
} else {
|
||||
std::fs::copy(e.path(), to.join(e.file_name())).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
/// Any record a project can hold reads back as written.
|
||||
#[test]
|
||||
fn prop_project_set_round_trips(
|
||||
status in 0usize..4,
|
||||
kind in proptest::option::of(0usize..4),
|
||||
party in proptest::option::of(1u64..1000),
|
||||
desc in proptest::option::of("[a-z ]{1,20}"),
|
||||
start in proptest::option::of(0u32..3000),
|
||||
links in proptest::collection::vec("[a-z]{1,10}", 0..8),
|
||||
) {
|
||||
let f = ProjectFacts {
|
||||
description: desc,
|
||||
status: ProjectStatus::ALL[status],
|
||||
kind: kind.map(|k| ProjectKind::ALL[k]),
|
||||
party,
|
||||
started_on: start.map(|n| d(2020, 1, 1) + chrono::Days::new(u64::from(n))),
|
||||
ended_on: None,
|
||||
links,
|
||||
notes: None,
|
||||
};
|
||||
let payload = ledger_store::meta::project_set(7, "Harbor", &f);
|
||||
let line = ledger_store::journal::JournalLine {
|
||||
seq: 1,
|
||||
ts: String::new(),
|
||||
line_type: "project.set".into(),
|
||||
payload,
|
||||
prev: String::new(),
|
||||
};
|
||||
let back = ledger_store::meta::project_set_of(&line).unwrap();
|
||||
proptest::prop_assert_eq!(back, (7, "Harbor".to_string(), f));
|
||||
}
|
||||
}
|
||||
@@ -609,6 +609,7 @@ fn decisions_and_open_items() {
|
||||
evidence_attachment: evidence,
|
||||
supersedes,
|
||||
citation: Some("Treas. Reg. 1.263(a)-1(f)".into()),
|
||||
projects: vec![],
|
||||
};
|
||||
assert_eq!(s.record_decision(&decision(None, Some(doc))).unwrap(), 1);
|
||||
assert_eq!(s.record_decision(&decision(Some(1), None)).unwrap(), 2);
|
||||
@@ -756,6 +757,7 @@ fn run_op(s: &mut Store, op: &Op) {
|
||||
evidence_attachment: None,
|
||||
supersedes: *sup,
|
||||
citation: None,
|
||||
projects: vec![],
|
||||
})
|
||||
.map(|_| ()),
|
||||
Op::Raise => s
|
||||
@@ -978,6 +980,7 @@ fn write_p1_fixture() {
|
||||
evidence_attachment: Some(doc),
|
||||
supersedes: None,
|
||||
citation: Some("Treas. Reg. 1.263(a)-1(f)".into()),
|
||||
projects: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
s.record_decision(&NewDecision {
|
||||
@@ -988,6 +991,7 @@ fn write_p1_fixture() {
|
||||
evidence_attachment: None,
|
||||
supersedes: Some(1),
|
||||
citation: None,
|
||||
projects: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
s.raise_open_item(&NewOpenItem {
|
||||
|
||||
Reference in new issue
Block a user