|
|
|
@@ -283,6 +283,35 @@ PROVISIONAL_SELECTION_RE = re.compile(
|
|
|
|
|
),
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
PROVISIONAL_SELECTED_COUNT_RES = (
|
|
|
|
|
re.compile(
|
|
|
|
|
r"\b(?:choose|select)\s+(?:exactly\s+)?(?P<count>{})\b".format(COUNT_TOKEN),
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
),
|
|
|
|
|
re.compile(
|
|
|
|
|
r"\b(?:with\s+)?(?:exactly\s+)?(?P<count>{})\s+selected\b".format(COUNT_TOKEN),
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
),
|
|
|
|
|
re.compile(
|
|
|
|
|
r"\bselected\s+(?:exactly\s+)?(?P<count>{})\b".format(COUNT_TOKEN),
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
PROVISIONAL_OFFERED_COUNT_RES = (
|
|
|
|
|
re.compile(
|
|
|
|
|
r"\b(?P<count>{})\b(?:\s+[a-z][a-z-]*){{0,6}}\s+offers?\b".format(
|
|
|
|
|
COUNT_TOKEN
|
|
|
|
|
),
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
),
|
|
|
|
|
re.compile(
|
|
|
|
|
r"\b(?:of|from)\s+(?:the\s+)?(?P<count>{})\b"
|
|
|
|
|
r"[^\n|.]{{0,100}}\bprovisional\b[^\n|.]{{0,60}}\brecruits?\b".format(
|
|
|
|
|
COUNT_TOKEN
|
|
|
|
|
),
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
REVISABLE_SELECTION_RE = re.compile(
|
|
|
|
|
r"\b(?:revis(?:e|ed|able)|change(?:d|able)?|edit(?:ed|able)?|reselect|undo|uncommitted)\b",
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
@@ -290,6 +319,7 @@ REVISABLE_SELECTION_RE = re.compile(
|
|
|
|
|
BEFORE_RUN_CREATION_RE = re.compile(
|
|
|
|
|
r"(?:\bbefore\b.{0,60}\brun\s+creat(?:ion|ed?)\b|"
|
|
|
|
|
r"\buntil\b.{0,60}\brun\s+(?:creation|commit)|"
|
|
|
|
|
r"\bbefore\s+(?:the\s+)?commit\b|\buntil\s+(?:the\s+)?commit\b|"
|
|
|
|
|
r"\bno\s+run\s+(?:is\s+)?created\b)",
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
@@ -346,6 +376,40 @@ RECRUIT_PART_FIELDS = (
|
|
|
|
|
re.compile(r"\b(?:voice\s+effort\s+sets?|audio)\b", re.IGNORECASE),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
RECRUIT_ART_BINDING_PATTERNS = {
|
|
|
|
|
"body_frame": re.compile(
|
|
|
|
|
r"\bART-REC-BODY-|\bbody\s+(?:foundations?|frames?)\b", re.IGNORECASE
|
|
|
|
|
),
|
|
|
|
|
"face_shape": re.compile(
|
|
|
|
|
r"\bART-REC-FACE-|\bface\s+(?:structures?|shapes?)\b", re.IGNORECASE
|
|
|
|
|
),
|
|
|
|
|
"skin_palette": re.compile(
|
|
|
|
|
r"\bART-REC-SKINPAL-|\bskin\s+(?:ramps?|palettes?)\b", re.IGNORECASE
|
|
|
|
|
),
|
|
|
|
|
"hair_shape": re.compile(
|
|
|
|
|
r"\bART-REC-HAIR-|\bhair\s+(?:famil(?:y|ies)|shapes?)\b", re.IGNORECASE
|
|
|
|
|
),
|
|
|
|
|
"hair_palette": re.compile(
|
|
|
|
|
r"\bART-REC-HAIRPAL-|\bhair\s+(?:ramps?|palettes?)\b", re.IGNORECASE
|
|
|
|
|
),
|
|
|
|
|
"garment_cut": re.compile(
|
|
|
|
|
r"\bART-REC-GARMENT-|\b(?:garment\s+cuts?|underlayers?)\b", re.IGNORECASE
|
|
|
|
|
),
|
|
|
|
|
"garment_palette": re.compile(
|
|
|
|
|
r"\bART-REC-GARMENTPAL-|\b(?:garment\s+palettes?|underlayer\s+ramps?)\b",
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
),
|
|
|
|
|
"profession_overlay": re.compile(
|
|
|
|
|
r"\bART-PRO-|\bprofession\s+(?:kits?|overlays?)\b", re.IGNORECASE
|
|
|
|
|
),
|
|
|
|
|
"detail_mark": re.compile(
|
|
|
|
|
r"\bART-REC-DETAIL-|\b(?:face\s+detail\s+sets?|detail\s+marks?)\b",
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
),
|
|
|
|
|
"voice_effort_set": re.compile(
|
|
|
|
|
r"\b(?:audio|no\s+art|nonvisual)\b", re.IGNORECASE
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def relative_path(path: Path, root: Path) -> str:
|
|
|
|
@@ -1197,11 +1261,46 @@ def count_value(token: str) -> Optional[int]:
|
|
|
|
|
def provisional_selection_pairs(text: str) -> List[Tuple[int, int, int, int]]:
|
|
|
|
|
pairs: List[Tuple[int, int, int, int]] = []
|
|
|
|
|
for line_number, line in enumerate(text.splitlines(), 1):
|
|
|
|
|
for match in PROVISIONAL_SELECTION_RE.finditer(line):
|
|
|
|
|
direct_matches = list(PROVISIONAL_SELECTION_RE.finditer(line))
|
|
|
|
|
for match in direct_matches:
|
|
|
|
|
selected = count_value(match.group("selected"))
|
|
|
|
|
offered = count_value(match.group("offered"))
|
|
|
|
|
if selected is not None and offered is not None:
|
|
|
|
|
pairs.append((selected, offered, line_number, match.start() + 1))
|
|
|
|
|
if direct_matches or not re.search(r"\bprovisional\b", line, re.IGNORECASE):
|
|
|
|
|
continue
|
|
|
|
|
if not re.search(r"\b(?:recruits?|offers?|company)\b", line, re.IGNORECASE):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
selected_matches: List[re.Match] = []
|
|
|
|
|
for pattern in PROVISIONAL_SELECTED_COUNT_RES:
|
|
|
|
|
selected_matches = list(pattern.finditer(line))
|
|
|
|
|
if selected_matches:
|
|
|
|
|
break
|
|
|
|
|
offered_matches = []
|
|
|
|
|
for pattern in PROVISIONAL_OFFERED_COUNT_RES:
|
|
|
|
|
offered_matches = [
|
|
|
|
|
match
|
|
|
|
|
for match in pattern.finditer(line)
|
|
|
|
|
if not re.search(
|
|
|
|
|
r"\b(?:unchosen|selected|remaining|other)\b",
|
|
|
|
|
match.group(0),
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
if offered_matches:
|
|
|
|
|
break
|
|
|
|
|
selected_values = {
|
|
|
|
|
count_value(match.group("count")) for match in selected_matches
|
|
|
|
|
} - {None}
|
|
|
|
|
offered_values = {
|
|
|
|
|
count_value(match.group("count")) for match in offered_matches
|
|
|
|
|
} - {None}
|
|
|
|
|
if len(selected_values) == 1 and len(offered_values) == 1:
|
|
|
|
|
column = selected_matches[0].start() + 1 if selected_matches else 1
|
|
|
|
|
pairs.append(
|
|
|
|
|
(next(iter(selected_values)), next(iter(offered_values)), line_number, column)
|
|
|
|
|
)
|
|
|
|
|
return pairs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1241,19 +1340,39 @@ def validate_provisional_selection_presentation(
|
|
|
|
|
r"\brecruits?\b", row_text, re.IGNORECASE
|
|
|
|
|
):
|
|
|
|
|
primary_candidates.append((definition, row_text))
|
|
|
|
|
primary_valid = any(
|
|
|
|
|
expected in {(selected, offered) for selected, offered, _, _ in provisional_selection_pairs(row_text)}
|
|
|
|
|
primary_valid_ids = {
|
|
|
|
|
definition.identifier
|
|
|
|
|
for definition, row_text in primary_candidates
|
|
|
|
|
if expected
|
|
|
|
|
in {
|
|
|
|
|
(selected, offered)
|
|
|
|
|
for selected, offered, _, _ in provisional_selection_pairs(row_text)
|
|
|
|
|
}
|
|
|
|
|
and selection_is_revisable_before_creation(row_text)
|
|
|
|
|
for _, row_text in primary_candidates
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
primary_valid = bool(primary_valid_ids)
|
|
|
|
|
|
|
|
|
|
onboarding = section_lines(screen, re.compile(r"\bonboarding\b", re.IGNORECASE))
|
|
|
|
|
onboarding_valid = any(
|
|
|
|
|
expected
|
|
|
|
|
in {(selected, offered) for selected, offered, _, _ in provisional_selection_pairs(line)}
|
|
|
|
|
and selection_is_revisable_before_creation(line)
|
|
|
|
|
for _, line in onboarding
|
|
|
|
|
)
|
|
|
|
|
onboarding_by_screen: Dict[str, List[str]] = {
|
|
|
|
|
identifier: [] for identifier in primary_valid_ids
|
|
|
|
|
}
|
|
|
|
|
for _, line in onboarding:
|
|
|
|
|
for identifier in primary_valid_ids:
|
|
|
|
|
if re.search(r"`{}`".format(re.escape(identifier)), line):
|
|
|
|
|
onboarding_by_screen[identifier].append(line)
|
|
|
|
|
onboarding_valid = False
|
|
|
|
|
for lines in onboarding_by_screen.values():
|
|
|
|
|
grouped_text = "\n".join(lines)
|
|
|
|
|
if (
|
|
|
|
|
expected
|
|
|
|
|
in {
|
|
|
|
|
(selected, offered)
|
|
|
|
|
for selected, offered, _, _ in provisional_selection_pairs(grouped_text)
|
|
|
|
|
}
|
|
|
|
|
and selection_is_revisable_before_creation(grouped_text)
|
|
|
|
|
):
|
|
|
|
|
onboarding_valid = True
|
|
|
|
|
break
|
|
|
|
|
if primary_valid and onboarding_valid:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
@@ -1283,39 +1402,77 @@ def endpoint_kind(value: str) -> Optional[str]:
|
|
|
|
|
return "exposed" if exposed else "protected"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_ui_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]:
|
|
|
|
|
for line_number, line in enumerate(document.text.splitlines(), 1):
|
|
|
|
|
exposed = re.search(r"\b(?:first\s+)?exposed\b.{0,40}?\b(left|right)\b", line, re.IGNORECASE)
|
|
|
|
|
last = re.search(r"\blast\b.{0,40}?\b(left|right)\b", line, re.IGNORECASE)
|
|
|
|
|
if exposed is None or last is None or exposed.group(1).lower() == last.group(1).lower():
|
|
|
|
|
continue
|
|
|
|
|
sides = {exposed.group(1).lower(): "exposed", last.group(1).lower(): "protected"}
|
|
|
|
|
return sides["left"], sides["right"], line_number, exposed.start() + 1
|
|
|
|
|
return None
|
|
|
|
|
PARTY_ENDPOINT_EXPRESSION = (
|
|
|
|
|
r"(?:first\s+exposed|exposed(?:\s+(?:position\s+0\s+)?front)?|"
|
|
|
|
|
r"protected(?:\s+rear)?|rear|last|front)"
|
|
|
|
|
)
|
|
|
|
|
PARTY_SIDE_EXPRESSION = r"(?:screen[- ]?)?(?:left|right)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_art_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]:
|
|
|
|
|
def extract_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]:
|
|
|
|
|
direction_re = re.compile(
|
|
|
|
|
r"\bleft\s*-?to\s*-?right\b\s+from\s+(.{1,50}?)\s+to\s+(.{1,50}?)(?:[.;]|$)",
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
for line_number, line in enumerate(document.text.splitlines(), 1):
|
|
|
|
|
match = direction_re.search(line)
|
|
|
|
|
concept_then_side_re = re.compile(
|
|
|
|
|
r"\b(?P<concept>{})\b[^.;|]{{0,40}}?\b(?P<side>{})\b".format(
|
|
|
|
|
PARTY_ENDPOINT_EXPRESSION, PARTY_SIDE_EXPRESSION
|
|
|
|
|
),
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
side_then_concept_re = re.compile(
|
|
|
|
|
r"\b(?P<side>{})\b\s*(?:is|=|:)\s*(?:the\s+)?"
|
|
|
|
|
r"(?P<concept>{})\b".format(
|
|
|
|
|
PARTY_SIDE_EXPRESSION, PARTY_ENDPOINT_EXPRESSION
|
|
|
|
|
),
|
|
|
|
|
re.IGNORECASE,
|
|
|
|
|
)
|
|
|
|
|
lines = document.text.splitlines()
|
|
|
|
|
for index in range(len(lines)):
|
|
|
|
|
window_lines: List[str] = []
|
|
|
|
|
for candidate in lines[index : index + 3]:
|
|
|
|
|
if not candidate.strip() and window_lines:
|
|
|
|
|
break
|
|
|
|
|
window_lines.append(candidate.strip())
|
|
|
|
|
window = re.sub(r"[`*]", "", " ".join(window_lines))
|
|
|
|
|
match = direction_re.search(window)
|
|
|
|
|
if match:
|
|
|
|
|
left = endpoint_kind(match.group(1))
|
|
|
|
|
right = endpoint_kind(match.group(2))
|
|
|
|
|
if left is not None and right is not None and left != right:
|
|
|
|
|
return left, right, line_number, match.start() + 1
|
|
|
|
|
return left, right, index + 1, match.start() + 1
|
|
|
|
|
|
|
|
|
|
exposed = re.search(r"\b(?:exposed|front)\b.{0,40}?\b(left|right)\b", line, re.IGNORECASE)
|
|
|
|
|
protected = re.search(r"\b(?:protected|rear)\b.{0,40}?\b(left|right)\b", line, re.IGNORECASE)
|
|
|
|
|
if exposed is None or protected is None or exposed.group(1).lower() == protected.group(1).lower():
|
|
|
|
|
continue
|
|
|
|
|
sides = {exposed.group(1).lower(): "exposed", protected.group(1).lower(): "protected"}
|
|
|
|
|
return sides["left"], sides["right"], line_number, exposed.start() + 1
|
|
|
|
|
assignments: Dict[str, Set[str]] = {"left": set(), "right": set()}
|
|
|
|
|
for pattern in (concept_then_side_re, side_then_concept_re):
|
|
|
|
|
for endpoint_match in pattern.finditer(window):
|
|
|
|
|
concept = endpoint_kind(endpoint_match.group("concept"))
|
|
|
|
|
side_match = re.search(
|
|
|
|
|
r"\b(left|right)\b", endpoint_match.group("side"), re.IGNORECASE
|
|
|
|
|
)
|
|
|
|
|
if concept is not None and side_match is not None:
|
|
|
|
|
assignments[side_match.group(1).lower()].add(concept)
|
|
|
|
|
if (
|
|
|
|
|
len(assignments["left"]) == 1
|
|
|
|
|
and len(assignments["right"]) == 1
|
|
|
|
|
and assignments["left"] != assignments["right"]
|
|
|
|
|
):
|
|
|
|
|
return (
|
|
|
|
|
next(iter(assignments["left"])),
|
|
|
|
|
next(iter(assignments["right"])),
|
|
|
|
|
index + 1,
|
|
|
|
|
1,
|
|
|
|
|
)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_ui_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]:
|
|
|
|
|
return extract_party_direction(document)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_art_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]:
|
|
|
|
|
return extract_party_direction(document)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_party_direction(documents: Sequence[Document], issues: List[Issue]) -> None:
|
|
|
|
|
ui = document_by_path(documents, UI_PATH)
|
|
|
|
|
art = document_by_path(documents, ART_PATH)
|
|
|
|
@@ -1430,17 +1587,26 @@ def find_crosswalk_rows(
|
|
|
|
|
if header is None:
|
|
|
|
|
candidate_columns: Dict[str, int] = {}
|
|
|
|
|
for index, cell in enumerate(normalized):
|
|
|
|
|
if "field" in cell and ("content" in cell or cell == "field"):
|
|
|
|
|
if "field" in cell and (
|
|
|
|
|
"content" in cell
|
|
|
|
|
or "recruit" in cell
|
|
|
|
|
or "record" in cell
|
|
|
|
|
or cell == "field"
|
|
|
|
|
):
|
|
|
|
|
candidate_columns["field"] = index
|
|
|
|
|
elif "content" in cell and re.search(r"\b(?:count|options?|values?)\b", cell):
|
|
|
|
|
elif "stored" in cell and "authority" in cell:
|
|
|
|
|
candidate_columns["content_count"] = index
|
|
|
|
|
elif "content" in cell and re.search(
|
|
|
|
|
r"\b(?:count|options?|values?)\b", cell
|
|
|
|
|
):
|
|
|
|
|
candidate_columns["content_count"] = index
|
|
|
|
|
elif "art" in cell and re.search(r"\b(?:source|target|library|binding)\b", cell):
|
|
|
|
|
candidate_columns["art_source"] = index
|
|
|
|
|
elif "art" in cell and re.search(r"\b(?:count|options?|masters?|values?)\b", cell):
|
|
|
|
|
candidate_columns["art_count"] = index
|
|
|
|
|
elif re.search(r"\b(?:mapping|resolution|rule)\b", cell):
|
|
|
|
|
elif re.search(r"\b(?:mapping|resolution|rule|derived\s+assembly)\b", cell):
|
|
|
|
|
candidate_columns["mapping"] = index
|
|
|
|
|
if set(candidate_columns) == {"field", "content_count", "art_source", "art_count", "mapping"}:
|
|
|
|
|
if {"field", "content_count", "art_source"} <= set(candidate_columns):
|
|
|
|
|
header = cells
|
|
|
|
|
columns = candidate_columns
|
|
|
|
|
continue
|
|
|
|
@@ -1457,7 +1623,7 @@ def find_crosswalk_rows(
|
|
|
|
|
rows[field] = (cells, line_number, dict(columns))
|
|
|
|
|
if header is None:
|
|
|
|
|
return {}, [
|
|
|
|
|
"crosswalk table requires content field/count, art source/count, and deterministic mapping columns"
|
|
|
|
|
"crosswalk table requires recruit field, content authority/count, and art binding columns"
|
|
|
|
|
]
|
|
|
|
|
return rows, []
|
|
|
|
|
|
|
|
|
@@ -1472,6 +1638,22 @@ def mapping_is_deterministic(value: str) -> bool:
|
|
|
|
|
) is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def embedded_count(value: str) -> Optional[int]:
|
|
|
|
|
counts = {int(match.group(0)) for match in re.finditer(r"(?<![A-Z0-9-])\d+(?![A-Z0-9-])", value)}
|
|
|
|
|
return next(iter(counts)) if len(counts) == 1 else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def bounded_art_binding_count(value: str) -> Optional[int]:
|
|
|
|
|
identifiers: Set[str] = set()
|
|
|
|
|
for match in CODE_SPAN_RE.finditer(value):
|
|
|
|
|
expression = match.group(1).strip()
|
|
|
|
|
expanded = expand_compact_id_expression(expression)
|
|
|
|
|
if expanded is None or not all(identifier.startswith("ART-") for identifier in expanded):
|
|
|
|
|
continue
|
|
|
|
|
identifiers.update(expanded)
|
|
|
|
|
return len(identifiers) if identifiers else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_recruit_art_crosswalk(documents: Sequence[Document], issues: List[Issue]) -> None:
|
|
|
|
|
content = document_by_path(documents, RECRUIT_PATH)
|
|
|
|
|
art = document_by_path(documents, ART_PATH)
|
|
|
|
@@ -1492,29 +1674,40 @@ def validate_recruit_art_crosswalk(documents: Sequence[Document], issues: List[I
|
|
|
|
|
errors.append("{} lacks an art crosswalk row".format(field))
|
|
|
|
|
continue
|
|
|
|
|
cells, _, columns = row
|
|
|
|
|
content_count = count_value(normalize_cell(cells[columns["content_count"]]))
|
|
|
|
|
art_count_text = normalize_cell(cells[columns["art_count"]])
|
|
|
|
|
art_count = count_value(art_count_text)
|
|
|
|
|
content_authority = normalize_cell(cells[columns["content_count"]])
|
|
|
|
|
content_count = embedded_count(content_authority)
|
|
|
|
|
source = normalize_cell(cells[columns["art_source"]])
|
|
|
|
|
mapping = normalize_cell(cells[columns["mapping"]])
|
|
|
|
|
explicit_art_count = (
|
|
|
|
|
embedded_count(normalize_cell(cells[columns["art_count"]]))
|
|
|
|
|
if "art_count" in columns
|
|
|
|
|
else None
|
|
|
|
|
)
|
|
|
|
|
binding_count = bounded_art_binding_count(cells[columns["art_source"]])
|
|
|
|
|
art_count = explicit_art_count if "art_count" in columns else binding_count
|
|
|
|
|
mapping = (
|
|
|
|
|
normalize_cell(cells[columns["mapping"]])
|
|
|
|
|
if "mapping" in columns
|
|
|
|
|
else " | ".join(normalize_cell(cell) for cell in cells)
|
|
|
|
|
)
|
|
|
|
|
binding_pattern = RECRUIT_ART_BINDING_PATTERNS[field]
|
|
|
|
|
matching_sources = {
|
|
|
|
|
count for label, count in source_counts if source_pattern.search(label)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if content_count != expected:
|
|
|
|
|
errors.append(
|
|
|
|
|
"{} crosswalk content count is {}; expected {}".format(field, content_count, expected)
|
|
|
|
|
)
|
|
|
|
|
if field == "voice_effort_set":
|
|
|
|
|
if not re.search(r"\b(?:audio|nonvisual|no\s+art)\b", source, re.IGNORECASE):
|
|
|
|
|
if not binding_pattern.search("{} {}".format(content_authority, source)):
|
|
|
|
|
errors.append("voice_effort_set must be explicitly audio-owned/nonvisual")
|
|
|
|
|
if art_count not in (None, 0):
|
|
|
|
|
errors.append("voice_effort_set must not declare visual masters")
|
|
|
|
|
else:
|
|
|
|
|
if not source_pattern.search(source):
|
|
|
|
|
if not binding_pattern.search(source):
|
|
|
|
|
errors.append("{} maps to an unrecognized art source".format(field))
|
|
|
|
|
if art_count != expected:
|
|
|
|
|
if art_count is not None and art_count != expected:
|
|
|
|
|
errors.append("{} art count is {}; expected {}".format(field, art_count, expected))
|
|
|
|
|
matching_sources = {
|
|
|
|
|
count for label, count in source_counts if source_pattern.search(label)
|
|
|
|
|
}
|
|
|
|
|
if matching_sources != {expected}:
|
|
|
|
|
errors.append(
|
|
|
|
|
"{} art source declares {}; expected exactly {}".format(
|
|
|
|
@@ -1523,7 +1716,18 @@ def validate_recruit_art_crosswalk(documents: Sequence[Document], issues: List[I
|
|
|
|
|
expected,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if not mapping_is_deterministic(mapping):
|
|
|
|
|
if art_count is None and matching_sources != {expected}:
|
|
|
|
|
errors.append("{} lacks a bounded art count".format(field))
|
|
|
|
|
deterministic_mapping = (
|
|
|
|
|
field == "voice_effort_set"
|
|
|
|
|
or mapping_is_deterministic(mapping)
|
|
|
|
|
or binding_count == expected
|
|
|
|
|
or (
|
|
|
|
|
re.search(r"\bone\b", source, re.IGNORECASE) is not None
|
|
|
|
|
and matching_sources == {expected}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if not deterministic_mapping:
|
|
|
|
|
errors.append("{} lacks a deterministic mapping rule".format(field))
|
|
|
|
|
|
|
|
|
|
if not errors:
|
|
|
|
@@ -2359,6 +2563,108 @@ Region coverage: `ART-REG-01..08`.
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def replace_fixture_text(path: Path, old: str, new: str) -> None:
|
|
|
|
|
text = path.read_text(encoding="utf-8")
|
|
|
|
|
if old not in text:
|
|
|
|
|
raise AssertionError("fixture rewrite target not found in {}: {}".format(path, old[:80]))
|
|
|
|
|
path.write_text(text.replace(old, new, 1), encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rewrite_fixture_with_integrated_shapes(root: Path) -> None:
|
|
|
|
|
screen_path = root / SCREEN_PATH
|
|
|
|
|
replace_fixture_text(
|
|
|
|
|
screen_path,
|
|
|
|
|
"Choose two provisional Recruits from four generated offers",
|
|
|
|
|
"Compare four stable provisional generated Recruit offers and choose exactly two before Run creation",
|
|
|
|
|
)
|
|
|
|
|
replace_fixture_text(
|
|
|
|
|
screen_path,
|
|
|
|
|
"| Opening choice | `SYS-005` | Choose two provisional Recruits from four generated offers | Choice is revisable before Run creation |",
|
|
|
|
|
"| Provisional Company | `SYS-005` stages `A-C` | inspect four stable offers and choose exactly two, then review both | selection alone creates no run |\n"
|
|
|
|
|
"| Run creation | `SYS-005` stages `D-E` | confirm selected two and the unchosen consequence | `Revise Recruits` remains legal before commit |",
|
|
|
|
|
)
|
|
|
|
|
replace_fixture_text(
|
|
|
|
|
root / UI_PATH,
|
|
|
|
|
"The party cord runs from First exposed at left to Last at right.",
|
|
|
|
|
"The party cord runs from `Protected rear` at left to `Exposed front` at right.",
|
|
|
|
|
)
|
|
|
|
|
art_path = root / ART_PATH
|
|
|
|
|
replace_fixture_text(
|
|
|
|
|
art_path,
|
|
|
|
|
"The player party reads left-to-right from exposed front to protected rear.",
|
|
|
|
|
"Screen-left is the protected rear and `Last`; screen-right is `First exposed` and the party front.",
|
|
|
|
|
)
|
|
|
|
|
old_crosswalk = """### 4.3 Recruit Content/Art Crosswalk
|
|
|
|
|
|
|
|
|
|
| Content field | Content options | Art source | Art masters | Mapping rule |
|
|
|
|
|
|---|---:|---|---:|---|
|
|
|
|
|
| `body_frame` | 8 | Body foundations | 8 | same stable index |
|
|
|
|
|
| `face_shape` | 12 | Face structures | 12 | same stable index |
|
|
|
|
|
| `skin_palette` | 12 | Skin ramps | 12 | same stable index |
|
|
|
|
|
| `hair_shape` | 24 | Hair families | 24 | same stable index |
|
|
|
|
|
| `hair_palette` | 10 | Hair ramps | 10 | same stable index |
|
|
|
|
|
| `garment_cut` | 12 | Underlayers | 12 | same stable index |
|
|
|
|
|
| `garment_palette` | 16 | Underlayer ramps | 16 | same stable index |
|
|
|
|
|
| `profession_overlay` | 42 | Profession kits | 42 | same stable index |
|
|
|
|
|
| `detail_mark` | 20 | Face detail sets | 20 | same stable index |
|
|
|
|
|
| `voice_effort_set` | 8 | Audio-owned nonvisual field | N/A | audio-owned deterministic field value |
|
|
|
|
|
"""
|
|
|
|
|
integrated_crosswalk = """### 4.3 Recruit Record To Art Crosswalk
|
|
|
|
|
|
|
|
|
|
| Recruit record field | Stored authority | Art binding | Derived assembly and runtime output |
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
| `voice_effort_set` | Audio field with 8 content options | No art master | Audio binds independently to the recruit ID. |
|
|
|
|
|
| `body_frame` | Visual grammar: 8 options | `ART-REC-BODY-01..08` | Shared body layers use the stored field. |
|
|
|
|
|
| `face_shape` | Visual grammar: 12 options | `ART-REC-FACE-01..12` | Portrait assembly uses the stored field. |
|
|
|
|
|
| `skin_palette` | Visual grammar: 12 options | `ART-REC-SKINPAL-01..12` | Palette data uses the stored field. |
|
|
|
|
|
| `hair_shape` | Visual grammar: 24 options | `ART-REC-HAIR-01..24` | Hair assemblies use the stored field. |
|
|
|
|
|
| `hair_palette` | Visual grammar: 10 options | `ART-REC-HAIRPAL-01..10` | Palette data uses the stored field. |
|
|
|
|
|
| `garment_cut` | Visual grammar: 12 options | `ART-REC-GARMENT-01..12` | Garment layers use the stored field. |
|
|
|
|
|
| `garment_palette` | Visual grammar: 16 options | `ART-REC-GARMENTPAL-01..16` | Palette data uses the stored field. |
|
|
|
|
|
| `profession_overlay` | Visual grammar: 42 options | One `ART-PRO-B*`, `ART-PRO-A*`, or `ART-PRO-H*` package | The selected profession owns one binding. |
|
|
|
|
|
| `detail_mark` | Visual grammar: 20 options | `ART-REC-DETAIL-01..20` | Detail assembly uses the stored field. |
|
|
|
|
|
"""
|
|
|
|
|
replace_fixture_text(art_path, old_crosswalk, integrated_crosswalk)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mutate_integrated_provisional_fixture(root: Path) -> None:
|
|
|
|
|
rewrite_fixture_with_integrated_shapes(root)
|
|
|
|
|
replace_fixture_text(
|
|
|
|
|
root / SCREEN_PATH,
|
|
|
|
|
"choose exactly two before Run creation",
|
|
|
|
|
"choose exactly one before Run creation",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mutate_integrated_direction_fixture(root: Path) -> None:
|
|
|
|
|
rewrite_fixture_with_integrated_shapes(root)
|
|
|
|
|
replace_fixture_text(
|
|
|
|
|
root / ART_PATH,
|
|
|
|
|
"Screen-left is the protected rear and `Last`; screen-right is `First exposed` and the party front.",
|
|
|
|
|
"Screen-left is `First exposed` and the party front; screen-right is the protected rear and `Last`.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mutate_integrated_crosswalk_fixture(root: Path) -> None:
|
|
|
|
|
rewrite_fixture_with_integrated_shapes(root)
|
|
|
|
|
replace_fixture_text(root / ART_PATH, "`ART-REC-BODY-01..08`", "`ART-REC-BODY-01..07`")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mutate_integrated_audio_screen_fixture(root: Path) -> None:
|
|
|
|
|
rewrite_fixture_with_integrated_shapes(root)
|
|
|
|
|
replace_fixture_text(root / AUDIO_PATH, "`OUT-006`, ", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mutate_integrated_reference_scope_fixture(root: Path) -> None:
|
|
|
|
|
rewrite_fixture_with_integrated_shapes(root)
|
|
|
|
|
path = root / REFERENCE_SCOPE_PATH
|
|
|
|
|
path.write_text(
|
|
|
|
|
path.read_text(encoding="utf-8")
|
|
|
|
|
+ "Approved scope: 30 named recruitable characters require profession affinities and relationship hooks.\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mutate_remove_line(path: Path, needle: str) -> None:
|
|
|
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
|
|
|
for index, line in enumerate(lines):
|
|
|
|
@@ -2416,6 +2722,15 @@ def run_self_test() -> None:
|
|
|
|
|
if expanded is None or len(expanded) != expected:
|
|
|
|
|
raise AssertionError("range regression failed: {}".format(expression))
|
|
|
|
|
|
|
|
|
|
rewrite_fixture_with_integrated_shapes(root)
|
|
|
|
|
integrated_shape = audit(root)
|
|
|
|
|
if not integrated_shape.ok:
|
|
|
|
|
rendered = "\n".join(
|
|
|
|
|
"{} {}:{} {}".format(item.code, item.path, item.line, item.message)
|
|
|
|
|
for item in integrated_shape.issues
|
|
|
|
|
)
|
|
|
|
|
raise AssertionError("integrated-shape passing fixture failed:\n{}".format(rendered))
|
|
|
|
|
|
|
|
|
|
cases: Sequence[Tuple[str, Callable[[Path], None], str]] = (
|
|
|
|
|
(
|
|
|
|
|
"exact-count",
|
|
|
|
@@ -2536,66 +2851,37 @@ def run_self_test() -> None:
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"provisional-selection-presentation",
|
|
|
|
|
lambda root: (root / SCREEN_PATH).write_text(
|
|
|
|
|
(root / SCREEN_PATH)
|
|
|
|
|
.read_text(encoding="utf-8")
|
|
|
|
|
.replace(
|
|
|
|
|
"Choose two provisional Recruits from four generated offers",
|
|
|
|
|
"Choose one provisional Recruit from four generated offers",
|
|
|
|
|
1,
|
|
|
|
|
),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
),
|
|
|
|
|
mutate_integrated_provisional_fixture,
|
|
|
|
|
"PROVISIONAL_SELECTION_PRESENTATION_MISMATCH",
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"party-direction",
|
|
|
|
|
lambda root: (root / ART_PATH).write_text(
|
|
|
|
|
(root / ART_PATH)
|
|
|
|
|
.read_text(encoding="utf-8")
|
|
|
|
|
.replace(
|
|
|
|
|
"left-to-right from exposed front to protected rear",
|
|
|
|
|
"left-to-right from protected rear to exposed front",
|
|
|
|
|
1,
|
|
|
|
|
),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
),
|
|
|
|
|
mutate_integrated_direction_fixture,
|
|
|
|
|
"PARTY_DIRECTION_MISMATCH",
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"recruit-art-crosswalk",
|
|
|
|
|
lambda root: (root / ART_PATH).write_text(
|
|
|
|
|
(root / ART_PATH)
|
|
|
|
|
.read_text(encoding="utf-8")
|
|
|
|
|
.replace(
|
|
|
|
|
"| `body_frame` | 8 | Body foundations | 8 |",
|
|
|
|
|
"| `body_frame` | 8 | Body foundations | 12 |",
|
|
|
|
|
1,
|
|
|
|
|
),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
),
|
|
|
|
|
mutate_integrated_crosswalk_fixture,
|
|
|
|
|
"RECRUIT_ART_CROSSWALK_MISMATCH",
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"audio-screen-inventory",
|
|
|
|
|
lambda root: (root / AUDIO_PATH).write_text(
|
|
|
|
|
(root / AUDIO_PATH)
|
|
|
|
|
.read_text(encoding="utf-8")
|
|
|
|
|
.replace("`OUT-006`, ", "", 1),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
),
|
|
|
|
|
mutate_integrated_audio_screen_fixture,
|
|
|
|
|
"AUDIO_SCREEN_INVENTORY_MISMATCH",
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"reference-retired-scope",
|
|
|
|
|
lambda root: (root / REFERENCE_SCOPE_PATH).write_text(
|
|
|
|
|
(root / REFERENCE_SCOPE_PATH).read_text(encoding="utf-8")
|
|
|
|
|
+ "Approved scope: 30 named recruitable characters require profession affinities and relationship hooks.\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
),
|
|
|
|
|
mutate_integrated_reference_scope_fixture,
|
|
|
|
|
"REFERENCE_RETIRED_SCOPE",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
isolated_semantic_cases = {
|
|
|
|
|
"provisional-selection-presentation",
|
|
|
|
|
"party-direction",
|
|
|
|
|
"recruit-art-crosswalk",
|
|
|
|
|
"audio-screen-inventory",
|
|
|
|
|
"reference-retired-scope",
|
|
|
|
|
}
|
|
|
|
|
for name, mutate, expected_code in cases:
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix="abg-design-validator-{}-".format(name)) as temporary:
|
|
|
|
|
root = Path(temporary)
|
|
|
|
@@ -2607,6 +2893,12 @@ def run_self_test() -> None:
|
|
|
|
|
raise AssertionError(
|
|
|
|
|
"{} did not produce {}; got {}".format(name, expected_code, sorted(codes))
|
|
|
|
|
)
|
|
|
|
|
if name in isolated_semantic_cases and codes != {expected_code}:
|
|
|
|
|
raise AssertionError(
|
|
|
|
|
"{} was not isolated to {}; got {}".format(
|
|
|
|
|
name, expected_code, sorted(codes)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
print("ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases={}".format(len(cases) + 1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|