Normalize feat names (#8)
Reviewed-on: https://gitea.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/8 Co-authored-by: vickydotbat <vickydotbat@tutamail.com> Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
This commit is contained in:
@@ -68,6 +68,10 @@ Compact family-expansion shape:
|
|||||||
"dataset": "skills",
|
"dataset": "skills",
|
||||||
"predicate": "accessible"
|
"predicate": "accessible"
|
||||||
},
|
},
|
||||||
|
"title_style": {
|
||||||
|
"child_case": "lower",
|
||||||
|
"child_parenthetical": "comma"
|
||||||
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"skills:craft_alchemy": {
|
"skills:craft_alchemy": {
|
||||||
"ICON": "ife_foc_alchm"
|
"ICON": "ife_foc_alchm"
|
||||||
@@ -103,6 +107,10 @@ Weapon family shape:
|
|||||||
"dataset": "baseitems",
|
"dataset": "baseitems",
|
||||||
"column": "WeaponFocusFeat"
|
"column": "WeaponFocusFeat"
|
||||||
},
|
},
|
||||||
|
"title_style": {
|
||||||
|
"child_case": "lower",
|
||||||
|
"child_parenthetical": "preserve"
|
||||||
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"baseitems:heavymace": {
|
"baseitems:heavymace": {
|
||||||
"ICON": "ife_wepfoc_Lma"
|
"ICON": "ife_wepfoc_Lma"
|
||||||
@@ -118,7 +126,7 @@ Rules:
|
|||||||
- `family` is descriptive only; the builder does not hardcode family names
|
- `family` is descriptive only; the builder does not hardcode family names
|
||||||
- family behavior is driven by authored fields such as `template_fields`,
|
- family behavior is driven by authored fields such as `template_fields`,
|
||||||
`default_fields`, `apply_after_modules`, `identity_source`, `child_ref_field`,
|
`default_fields`, `apply_after_modules`, `identity_source`, `child_ref_field`,
|
||||||
and `auto_prereq_fields`
|
`auto_prereq_fields`, and optional `title_style`
|
||||||
- `default_fields` is a family-wide shared layer; it applies to existing and newly
|
- `default_fields` is a family-wide shared layer; it applies to existing and newly
|
||||||
generated child rows before per-child overrides are merged
|
generated child rows before per-child overrides are merged
|
||||||
- `apply_after_modules: true` makes the generated family the final authoritative shared
|
- `apply_after_modules: true` makes the generated family the final authoritative shared
|
||||||
@@ -131,6 +139,16 @@ Rules:
|
|||||||
- family-expansion files must declare `template`, `family_key`, and `child_source.dataset`
|
- family-expansion files must declare `template`, `family_key`, and `child_source.dataset`
|
||||||
- `child_source.column` is used when expansion is gated by a non-null source field
|
- `child_source.column` is used when expansion is gated by a non-null source field
|
||||||
- `child_source.predicate: "accessible"` currently means `HideFromLevelUp != 1`
|
- `child_source.predicate: "accessible"` currently means `HideFromLevelUp != 1`
|
||||||
|
- `title_style` only changes generated child display text while composing titles;
|
||||||
|
generated feat keys, TLK keys, row IDs, family metadata, and source references
|
||||||
|
remain tied to generated family identity
|
||||||
|
- `title_style.child_case` defaults to `preserve`; `lower` lowercases the resolved
|
||||||
|
child display text after parenthetical formatting
|
||||||
|
- `title_style.child_parenthetical` defaults to `preserve`; `comma` flattens one
|
||||||
|
child parenthetical suffix such as `Knowledge (local)` into
|
||||||
|
`Knowledge, local` before the child text is wrapped by the family title
|
||||||
|
- unsupported `title_style` values fail generated-family validation instead of
|
||||||
|
silently changing title behavior
|
||||||
- skill focus families intentionally do not set `allow_existing_only`, so any new
|
- skill focus families intentionally do not set `allow_existing_only`, so any new
|
||||||
accessible skill automatically receives matching focus rows
|
accessible skill automatically receives matching focus rows
|
||||||
- compact `overrides` are keyed by source dataset key:
|
- compact `overrides` are keyed by source dataset key:
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ type familyExpansionSource struct {
|
|||||||
Predicate string
|
Predicate string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type familyExpansionTitleStyle struct {
|
||||||
|
ChildCase string
|
||||||
|
ChildParenthetical string
|
||||||
|
}
|
||||||
|
|
||||||
type familyExpansionSpec struct {
|
type familyExpansionSpec struct {
|
||||||
Family string
|
Family string
|
||||||
FamilyKey string
|
FamilyKey string
|
||||||
@@ -32,6 +37,7 @@ type familyExpansionSpec struct {
|
|||||||
AllowExistingOnly bool
|
AllowExistingOnly bool
|
||||||
AutoPrereqFields map[string]string
|
AutoPrereqFields map[string]string
|
||||||
LegacyFamilyKeys []string
|
LegacyFamilyKeys []string
|
||||||
|
TitleStyle familyExpansionTitleStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitFamilyExpansionIdentity(text string) familyIdentity {
|
func splitFamilyExpansionIdentity(text string) familyIdentity {
|
||||||
@@ -122,6 +128,10 @@ func parseFamilyExpansionSpec(path string, obj map[string]any) (familyExpansionS
|
|||||||
if err := validateLegacyFamilyKeys(familyKey, legacyFamilyKeys); err != nil {
|
if err := validateLegacyFamilyKeys(familyKey, legacyFamilyKeys); err != nil {
|
||||||
return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err)
|
return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
titleStyle, err := parseFamilyExpansionTitleStyle(obj["title_style"])
|
||||||
|
if err != nil {
|
||||||
|
return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err)
|
||||||
|
}
|
||||||
allowExistingOnly, err := parseOptionalBoolField(obj["allow_existing_only"], "allow_existing_only")
|
allowExistingOnly, err := parseOptionalBoolField(obj["allow_existing_only"], "allow_existing_only")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err)
|
return familyExpansionSpec{}, fmt.Errorf("generated file %s: %w", path, err)
|
||||||
@@ -142,9 +152,41 @@ func parseFamilyExpansionSpec(path string, obj map[string]any) (familyExpansionS
|
|||||||
AllowExistingOnly: allowExistingOnly,
|
AllowExistingOnly: allowExistingOnly,
|
||||||
AutoPrereqFields: autoPrereqFields,
|
AutoPrereqFields: autoPrereqFields,
|
||||||
LegacyFamilyKeys: legacyFamilyKeys,
|
LegacyFamilyKeys: legacyFamilyKeys,
|
||||||
|
TitleStyle: titleStyle,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseFamilyExpansionTitleStyle(raw any) (familyExpansionTitleStyle, error) {
|
||||||
|
style := familyExpansionTitleStyle{
|
||||||
|
ChildCase: "preserve",
|
||||||
|
ChildParenthetical: "preserve",
|
||||||
|
}
|
||||||
|
if raw == nil {
|
||||||
|
return style, nil
|
||||||
|
}
|
||||||
|
obj, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return familyExpansionTitleStyle{}, fmt.Errorf("title_style must be an object")
|
||||||
|
}
|
||||||
|
if childCase, ok := obj["child_case"].(string); ok && strings.TrimSpace(childCase) != "" {
|
||||||
|
style.ChildCase = strings.TrimSpace(childCase)
|
||||||
|
}
|
||||||
|
switch style.ChildCase {
|
||||||
|
case "preserve", "lower":
|
||||||
|
default:
|
||||||
|
return familyExpansionTitleStyle{}, fmt.Errorf("title_style.child_case must be preserve or lower")
|
||||||
|
}
|
||||||
|
if childParenthetical, ok := obj["child_parenthetical"].(string); ok && strings.TrimSpace(childParenthetical) != "" {
|
||||||
|
style.ChildParenthetical = strings.TrimSpace(childParenthetical)
|
||||||
|
}
|
||||||
|
switch style.ChildParenthetical {
|
||||||
|
case "preserve", "comma":
|
||||||
|
default:
|
||||||
|
return familyExpansionTitleStyle{}, fmt.Errorf("title_style.child_parenthetical must be preserve or comma")
|
||||||
|
}
|
||||||
|
return style, nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateLegacyFamilyKeys(familyKey string, legacyFamilyKeys []string) error {
|
func validateLegacyFamilyKeys(familyKey string, legacyFamilyKeys []string) error {
|
||||||
seen := map[string]string{}
|
seen := map[string]string{}
|
||||||
normalizedFamilyKey := normalizeKeyIdentity(familyKey)
|
normalizedFamilyKey := normalizeKeyIdentity(familyKey)
|
||||||
@@ -176,9 +218,10 @@ func isFamilyExpansionObject(obj map[string]any) bool {
|
|||||||
_, hasAllowExistingOnly := obj["allow_existing_only"]
|
_, hasAllowExistingOnly := obj["allow_existing_only"]
|
||||||
_, hasAutoPrereqFields := obj["auto_prereq_fields"]
|
_, hasAutoPrereqFields := obj["auto_prereq_fields"]
|
||||||
_, hasLegacyFamilyKeys := obj["legacy_family_keys"]
|
_, hasLegacyFamilyKeys := obj["legacy_family_keys"]
|
||||||
|
_, hasTitleStyle := obj["title_style"]
|
||||||
return hasFamilyKey || hasTemplate || hasChildSource || hasNamePrefix || hasLabelPrefix ||
|
return hasFamilyKey || hasTemplate || hasChildSource || hasNamePrefix || hasLabelPrefix ||
|
||||||
hasConstantPrefix || hasTemplateFields || hasDefaultFields || hasApplyAfterModules || hasChildRefField ||
|
hasConstantPrefix || hasTemplateFields || hasDefaultFields || hasApplyAfterModules || hasChildRefField ||
|
||||||
hasIdentitySource || hasAllowExistingOnly || hasAutoPrereqFields || hasLegacyFamilyKeys
|
hasIdentitySource || hasAllowExistingOnly || hasAutoPrereqFields || hasLegacyFamilyKeys || hasTitleStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
func optionalTrimmedString(obj map[string]any, field string) (string, bool) {
|
func optionalTrimmedString(obj map[string]any, field string) (string, bool) {
|
||||||
|
|||||||
@@ -1355,6 +1355,7 @@ type featGeneratedContext struct {
|
|||||||
dataDir string
|
dataDir string
|
||||||
lockData map[string]int
|
lockData map[string]int
|
||||||
supplementalID map[string]int
|
supplementalID map[string]int
|
||||||
|
baseDialog *legacyTLKData
|
||||||
tlkStateKeys map[string]struct{}
|
tlkStateKeys map[string]struct{}
|
||||||
existingFeat map[string]struct{}
|
existingFeat map[string]struct{}
|
||||||
datasets map[string]nativeDataset
|
datasets map[string]nativeDataset
|
||||||
@@ -1384,6 +1385,10 @@ func newFeatGeneratedContext(dataset nativeDataset, lockData map[string]int) (*f
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
baseDialog, err := loadBaseDialogData(filepath.Join(sourceDir, "base_dialog.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
tlkState, err := loadTLKState(filepath.Join(sourceDir, tlkStateFile))
|
tlkState, err := loadTLKState(filepath.Join(sourceDir, tlkStateFile))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1415,6 +1420,7 @@ func newFeatGeneratedContext(dataset nativeDataset, lockData map[string]int) (*f
|
|||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
lockData: lockCopy,
|
lockData: lockCopy,
|
||||||
supplementalID: supplementalID,
|
supplementalID: supplementalID,
|
||||||
|
baseDialog: baseDialog,
|
||||||
tlkStateKeys: tlkStateKeys,
|
tlkStateKeys: tlkStateKeys,
|
||||||
existingFeat: existingFeat,
|
existingFeat: existingFeat,
|
||||||
datasets: datasetMap,
|
datasets: datasetMap,
|
||||||
@@ -1870,10 +1876,8 @@ func buildFamilyExpansionGeneratedModule(path string, obj map[string]any, ctx *f
|
|||||||
return nil, fmt.Errorf("generated feat file %s: %w", path, err)
|
return nil, fmt.Errorf("generated feat file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
childToken := childTokenFromExpandedKey(featKey, spec.FamilyKey)
|
childToken := childTokenFromExpandedKey(featKey, spec.FamilyKey)
|
||||||
displayName := displayNameForGeneratedSource(spec.ChildSource.Dataset, row)
|
displayName := ctx.displayNameForGeneratedSource(spec.ChildSource.Dataset, row)
|
||||||
if spec.IdentitySource == "child_source_value" && childToken != "" && normalizeKeyIdentity(childToken) != normalizeKeyIdentity(slug) {
|
titleChildName := applyFamilyExpansionTitleStyle(displayName, spec.TitleStyle)
|
||||||
displayName = displayNameFromSlug(childToken)
|
|
||||||
}
|
|
||||||
labelSuffix := upperSnake(displayName)
|
labelSuffix := upperSnake(displayName)
|
||||||
override := map[string]any{
|
override := map[string]any{
|
||||||
"key": featKey,
|
"key": featKey,
|
||||||
@@ -1902,7 +1906,7 @@ func buildFamilyExpansionGeneratedModule(path string, obj map[string]any, ctx *f
|
|||||||
override["FEAT"] = map[string]any{
|
override["FEAT"] = map[string]any{
|
||||||
"tlk": map[string]any{
|
"tlk": map[string]any{
|
||||||
"key": featKey + ".name",
|
"key": featKey + ".name",
|
||||||
"text": fmt.Sprintf("%s (%s)", spec.NamePrefix, displayName),
|
"text": fmt.Sprintf("%s (%s)", spec.NamePrefix, titleChildName),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2160,7 +2164,10 @@ func displayNameForBaseitem(row map[string]any) string {
|
|||||||
return "Unknown Weapon"
|
return "Unknown Weapon"
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayNameForGeneratedSource(dataset string, row map[string]any) string {
|
func (c *featGeneratedContext) displayNameForGeneratedSource(dataset string, row map[string]any) string {
|
||||||
|
if text, ok := c.displayTextFromGeneratedValue(row["Name"]); ok {
|
||||||
|
return text
|
||||||
|
}
|
||||||
switch dataset {
|
switch dataset {
|
||||||
case "skills":
|
case "skills":
|
||||||
return displayNameForSkill(row)
|
return displayNameForSkill(row)
|
||||||
@@ -2183,6 +2190,64 @@ func displayNameForGeneratedSource(dataset string, row map[string]any) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *featGeneratedContext) displayTextFromGeneratedValue(value any) (string, bool) {
|
||||||
|
if text, ok := displayTextFromValue(value); ok {
|
||||||
|
return text, true
|
||||||
|
}
|
||||||
|
if c == nil || c.baseDialog == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
key := ""
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
if !isNumericText(typed) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(typed)
|
||||||
|
case int:
|
||||||
|
key = strconv.Itoa(typed)
|
||||||
|
case float64:
|
||||||
|
if typed != float64(int(typed)) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
key = strconv.Itoa(int(typed))
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
entry, ok := c.baseDialog.Entries[key]
|
||||||
|
if !ok || strings.TrimSpace(entry.Text) == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return normalizeDisplayName(strings.TrimSpace(entry.Text)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyFamilyExpansionTitleStyle(displayName string, style familyExpansionTitleStyle) string {
|
||||||
|
if style.ChildParenthetical == "comma" {
|
||||||
|
displayName = flattenGeneratedTitleChildParenthetical(displayName)
|
||||||
|
}
|
||||||
|
if style.ChildCase == "lower" {
|
||||||
|
displayName = strings.ToLower(displayName)
|
||||||
|
}
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenGeneratedTitleChildParenthetical(text string) string {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if !strings.HasSuffix(text, ")") || strings.Count(text, "(") != 1 || strings.Count(text, ")") != 1 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
open := strings.LastIndex(text, "(")
|
||||||
|
if open <= 0 || open >= len(text)-2 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
prefix := strings.TrimSpace(text[:open])
|
||||||
|
suffix := strings.TrimSpace(strings.TrimSuffix(text[open+1:], ")"))
|
||||||
|
if prefix == "" || suffix == "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return prefix + ", " + suffix
|
||||||
|
}
|
||||||
|
|
||||||
func displayNameForRaceFile(obj map[string]any) string {
|
func displayNameForRaceFile(obj map[string]any) string {
|
||||||
core, ok := obj["core"].(map[string]any)
|
core, ok := obj["core"].(map[string]any)
|
||||||
if ok {
|
if ok {
|
||||||
|
|||||||
@@ -2954,6 +2954,184 @@ func TestParseFamilyExpansionRejectsInvalidLegacyFamilyKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGeneratedWeaponFeatTitleStyleUsesSourceDisplayNameWithStableIdentity(t *testing.T) {
|
||||||
|
root := testProjectRoot(t)
|
||||||
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
||||||
|
"weapon_focus.json": `{
|
||||||
|
"family": "weapon_focus",
|
||||||
|
"family_key": "weapon_focus",
|
||||||
|
"template": "masterfeats:weaponfocus",
|
||||||
|
"name_prefix": "Weapon Focus",
|
||||||
|
"label_prefix": "FEAT_WEAPON_FOCUS",
|
||||||
|
"constant_prefix": "FEAT_WEAPON_FOCUS",
|
||||||
|
"identity_source": "child_source_value",
|
||||||
|
"child_source": {"dataset":"baseitems","column":"WeaponFocusFeat"},
|
||||||
|
"title_style": {"child_case":"lower","child_parenthetical":"preserve"},
|
||||||
|
"overrides": {}
|
||||||
|
}` + "\n",
|
||||||
|
}, map[string]int{"feat:weapon_focus_dwaxe": 3003})
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), `{
|
||||||
|
"entries": {
|
||||||
|
"83310": {"text": "Dwarven Waraxe"}
|
||||||
|
}
|
||||||
|
}`+"\n")
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{
|
||||||
|
"output": "baseitems.2da",
|
||||||
|
"columns": ["label", "Name", "WeaponFocusFeat"],
|
||||||
|
"rows": [
|
||||||
|
{"id": 108, "key": "baseitems:dwarvenwaraxe", "label": "dwaxe", "Name": "83310", "WeaponFocusFeat": "3003"}
|
||||||
|
]
|
||||||
|
}`+"\n")
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{"baseitems:dwarvenwaraxe":108}`+"\n")
|
||||||
|
|
||||||
|
module := buildGeneratedFeatModuleForTest(t, root, "weapon_focus.json")
|
||||||
|
override := generatedFeatOverrideByKey(t, module, "feat:weapon_focus_dwaxe")
|
||||||
|
if text := generatedFeatOverrideTitle(t, override); text != "Weapon Focus (dwarven waraxe)" {
|
||||||
|
t.Fatalf("expected source display title for stable generated feat identity, got %q", text)
|
||||||
|
}
|
||||||
|
if id, ok := override["id"].(int); !ok || id != 3003 {
|
||||||
|
t.Fatalf("expected generated title style to preserve compact row id 3003, got %#v", override["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedSkillFeatTitleStyleFlattensChildParenthetical(t *testing.T) {
|
||||||
|
root := testProjectRoot(t)
|
||||||
|
writeFeatGeneratedHarness(t, root, map[string]string{
|
||||||
|
"skill_focus.json": `{
|
||||||
|
"family": "skill_focus",
|
||||||
|
"family_key": "skill_focus",
|
||||||
|
"template": "masterfeats:skillfocus",
|
||||||
|
"name_prefix": "Skill Focus",
|
||||||
|
"label_prefix": "FEAT_SKILL_FOCUS",
|
||||||
|
"constant_prefix": "FEAT_SKILL_FOCUS",
|
||||||
|
"child_ref_field": "REQSKILL",
|
||||||
|
"child_source": {"dataset":"skills","predicate":"accessible"},
|
||||||
|
"title_style": {"child_case":"lower","child_parenthetical":"comma"},
|
||||||
|
"overrides": {}
|
||||||
|
}` + "\n",
|
||||||
|
}, map[string]int{})
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
|
||||||
|
"output": "skills.2da",
|
||||||
|
"columns": ["Label", "Name", "HideFromLevelUp"],
|
||||||
|
"rows": [
|
||||||
|
{"id": 10, "key": "skills:knowledge_local", "Label": "KnowledgeLocal", "Name": {"tlk": {"text": "Knowledge (local)"}}, "HideFromLevelUp": "0"}
|
||||||
|
]
|
||||||
|
}`+"\n")
|
||||||
|
writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:knowledge_local":10}`+"\n")
|
||||||
|
|
||||||
|
module := buildGeneratedFeatModuleForTest(t, root, "skill_focus.json")
|
||||||
|
override := generatedFeatOverrideByKey(t, module, "feat:skill_focus_knowledge_local")
|
||||||
|
if text := generatedFeatOverrideTitle(t, override); text != "Skill Focus (knowledge, local)" {
|
||||||
|
t.Fatalf("expected flattened generated skill title, got %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFamilyExpansionTitleStyleDefaultsAndValidation(t *testing.T) {
|
||||||
|
base := map[string]any{
|
||||||
|
"family": "skill_focus",
|
||||||
|
"family_key": "skill_focus",
|
||||||
|
"template": "masterfeats:skillfocus",
|
||||||
|
"child_source": map[string]any{"dataset": "skills", "predicate": "accessible"},
|
||||||
|
}
|
||||||
|
spec, err := parseFamilyExpansionSpec("default-title-style.json", base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse default title style: %v", err)
|
||||||
|
}
|
||||||
|
if spec.TitleStyle.ChildCase != "preserve" || spec.TitleStyle.ChildParenthetical != "preserve" {
|
||||||
|
t.Fatalf("expected default title style to preserve legacy behavior, got %#v", spec.TitleStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
titleStyle map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "child case", titleStyle: map[string]any{"child_case": "headline"}, want: "title_style.child_case"},
|
||||||
|
{name: "child parenthetical", titleStyle: map[string]any{"child_parenthetical": "nested"}, want: "title_style.child_parenthetical"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
obj := map[string]any{}
|
||||||
|
for key, value := range base {
|
||||||
|
obj[key] = value
|
||||||
|
}
|
||||||
|
obj["title_style"] = tc.titleStyle
|
||||||
|
_, err := parseFamilyExpansionSpec("invalid-title-style.json", obj)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "invalid-title-style.json") {
|
||||||
|
t.Fatalf("expected %q validation error with file context, got %v", tc.want, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGeneratedFeatModuleForTest(t *testing.T, root, name string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
datasets, err := discoverNativeDatasets(filepath.Join(root, "topdata", "data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discover datasets: %v", err)
|
||||||
|
}
|
||||||
|
var feat nativeDataset
|
||||||
|
for _, dataset := range datasets {
|
||||||
|
if dataset.Name == "feat" {
|
||||||
|
feat = dataset
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if feat.Name == "" {
|
||||||
|
t.Fatal("expected feat dataset")
|
||||||
|
}
|
||||||
|
lock, err := loadLockfile(feat.LockPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load feat lock: %v", err)
|
||||||
|
}
|
||||||
|
ctx, err := newFeatGeneratedContext(feat, lock)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new generated feat context: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(feat.GeneratedDir, name)
|
||||||
|
obj, err := loadJSONObject(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load generated family %s: %v", name, err)
|
||||||
|
}
|
||||||
|
module, err := buildFamilyExpansionGeneratedModule(path, obj, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build generated family %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return module
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedFeatOverrideByKey(t *testing.T, module map[string]any, key string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
overrides, ok := module["overrides"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected generated overrides, got %#v", module)
|
||||||
|
}
|
||||||
|
for _, raw := range overrides {
|
||||||
|
override, ok := raw.(map[string]any)
|
||||||
|
if ok && override["key"] == key {
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("expected generated override %q, got %#v", key, overrides)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedFeatOverrideTitle(t *testing.T, override map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
feat, ok := override["FEAT"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected generated FEAT TLK data, got %#v", override)
|
||||||
|
}
|
||||||
|
tlk, ok := feat["tlk"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected generated FEAT tlk block, got %#v", feat)
|
||||||
|
}
|
||||||
|
text, ok := tlk["text"].(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected generated FEAT text, got %#v", tlk)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateBaseitemsWeaponFeatColumnCompletenessRejectsPartialCoreFamilies(t *testing.T) {
|
func TestValidateBaseitemsWeaponFeatColumnCompletenessRejectsPartialCoreFamilies(t *testing.T) {
|
||||||
ctx := &featGeneratedContext{
|
ctx := &featGeneratedContext{
|
||||||
rowsByDataset: map[string]map[string]map[string]any{
|
rowsByDataset: map[string]map[string]map[string]any{
|
||||||
|
|||||||
@@ -474,6 +474,90 @@ func TestDeployWikiRenamesExistingPrefixedTopicWhenTitleIsLongEnough(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeployWikiRenamesManagedTopicWhenGeneratedTitleChanges(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sourceDir := filepath.Join(root, "pages")
|
||||||
|
if err := os.MkdirAll(filepath.Join(sourceDir, "feat"), 0755); err != nil {
|
||||||
|
t.Fatalf("create source dir: %v", err)
|
||||||
|
}
|
||||||
|
generated := `<!-- sow-topdata-wiki:page=feat:weapon:focus:dwaxe -->
|
||||||
|
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
|
||||||
|
<h1>Weapon Focus (dwarven waraxe)</h1>
|
||||||
|
<p>Generated weapon focus page</p>
|
||||||
|
<!-- sow-topdata-wiki:managed:end -->
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(sourceDir, "feat", "weapon:focus:dwaxe.html"), []byte(generated), 0644); err != nil {
|
||||||
|
t.Fatalf("write source page: %v", err)
|
||||||
|
}
|
||||||
|
manifestPath := filepath.Join(root, "deploy-manifest.json")
|
||||||
|
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
|
||||||
|
Version: "nodebb-v1",
|
||||||
|
Pages: map[string]wikiDeployManifestPage{
|
||||||
|
"feat:weapon:focus:dwaxe": {
|
||||||
|
Hash: computeManagedHash(generated),
|
||||||
|
Title: "Weapon Focus (Dwaxe)",
|
||||||
|
TopicTitle: "Weapon Focus (Dwaxe)",
|
||||||
|
Namespace: "feat",
|
||||||
|
TID: 7,
|
||||||
|
PID: 42,
|
||||||
|
CID: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("write deploy manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
renameCalls := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"response": map[string]any{
|
||||||
|
"pages": []map[string]any{
|
||||||
|
{"tid": 7, "title": "Weapon Focus (Dwaxe)", "titleLeaf": "Weapon Focus (Dwaxe)"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/move":
|
||||||
|
renameCalls++
|
||||||
|
var req struct {
|
||||||
|
TID int `json:"tid"`
|
||||||
|
CID int `json:"cid"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatalf("decode rename request: %v", err)
|
||||||
|
}
|
||||||
|
if req.TID != 7 || req.CID != 5 || req.Title != "Weapon Focus (dwarven waraxe)" {
|
||||||
|
t.Fatalf("unexpected rename request: %#v", req)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 7}})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
|
||||||
|
SourceDir: sourceDir,
|
||||||
|
Endpoint: server.URL,
|
||||||
|
Token: "nodebb-token",
|
||||||
|
ManifestPath: manifestPath,
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeployWikiWithOptions title rename failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.Renamed != 1 || result.Updated != 0 || result.Created != 0 || result.Archived != 0 || result.Purged != 0 || renameCalls != 1 {
|
||||||
|
t.Fatalf("expected generated title rename only, result=%#v renameCalls=%d", result, renameCalls)
|
||||||
|
}
|
||||||
|
entry := loadDeployManifest(manifestPath).Pages["feat:weapon:focus:dwaxe"]
|
||||||
|
if entry.Title != "Weapon Focus (dwarven waraxe)" || entry.TopicTitle != "Weapon Focus (dwarven waraxe)" {
|
||||||
|
t.Fatalf("expected renamed title state in manifest, got %#v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t *testing.T) {
|
func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
sourceDir := filepath.Join(root, "pages")
|
sourceDir := filepath.Join(root, "pages")
|
||||||
|
|||||||
Reference in New Issue
Block a user